chore: initial release
This commit is contained in:
107
lib/features/auth/providers/unified_auth_providers.dart
Normal file
107
lib/features/auth/providers/unified_auth_providers.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/auth/auth_state_manager.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
|
||||
/// Unified auth providers using the new auth state manager
|
||||
/// These replace the old auth providers for better efficiency
|
||||
|
||||
/// Login action provider
|
||||
final loginActionProvider = Provider.family<Future<bool>, Map<String, String>>((
|
||||
ref,
|
||||
credentials,
|
||||
) async {
|
||||
final authManager = ref.read(authStateManagerProvider.notifier);
|
||||
|
||||
final username = credentials['username']!;
|
||||
final password = credentials['password']!;
|
||||
final rememberCredentials = credentials['remember'] == 'true';
|
||||
|
||||
return await authManager.login(
|
||||
username,
|
||||
password,
|
||||
rememberCredentials: rememberCredentials,
|
||||
);
|
||||
});
|
||||
|
||||
/// Silent login action provider
|
||||
final silentLoginActionProvider = Provider<Future<bool>>((ref) async {
|
||||
final authManager = ref.read(authStateManagerProvider.notifier);
|
||||
return await authManager.silentLogin();
|
||||
});
|
||||
|
||||
/// Logout action provider
|
||||
final logoutActionProvider = Provider<Future<void>>((ref) async {
|
||||
final authManager = ref.read(authStateManagerProvider.notifier);
|
||||
await authManager.logout();
|
||||
});
|
||||
|
||||
/// Check if saved credentials exist
|
||||
final hasSavedCredentialsProvider2 = FutureProvider<bool>((ref) async {
|
||||
final authManager = ref.read(authStateManagerProvider.notifier);
|
||||
return await authManager.hasSavedCredentials();
|
||||
});
|
||||
|
||||
/// Computed providers for UI consumption
|
||||
/// These automatically update when auth state changes
|
||||
|
||||
final isAuthenticatedProvider2 = Provider<bool>((ref) {
|
||||
return ref.watch(
|
||||
authStateManagerProvider.select((state) => state.isAuthenticated),
|
||||
);
|
||||
});
|
||||
|
||||
final authTokenProvider3 = Provider<String?>((ref) {
|
||||
return ref.watch(authStateManagerProvider.select((state) => state.token));
|
||||
});
|
||||
|
||||
final currentUserProvider2 = Provider<dynamic>((ref) {
|
||||
return ref.watch(authStateManagerProvider.select((state) => state.user));
|
||||
});
|
||||
|
||||
final authErrorProvider3 = Provider<String?>((ref) {
|
||||
return ref.watch(authStateManagerProvider.select((state) => state.error));
|
||||
});
|
||||
|
||||
final isAuthLoadingProvider2 = Provider<bool>((ref) {
|
||||
return ref.watch(authStateManagerProvider.select((state) => state.isLoading));
|
||||
});
|
||||
|
||||
final authStatusProvider = Provider<AuthStatus>((ref) {
|
||||
return ref.watch(authStateManagerProvider.select((state) => state.status));
|
||||
});
|
||||
|
||||
/// Helper provider to trigger auth refresh
|
||||
final refreshAuthProvider = Provider<Future<void>>((ref) async {
|
||||
final authManager = ref.read(authStateManagerProvider.notifier);
|
||||
await authManager.refresh();
|
||||
});
|
||||
|
||||
/// Provider to watch for auth state changes and update API service
|
||||
final authApiIntegrationProvider = Provider<void>((ref) {
|
||||
ref.listen(authTokenProvider3, (previous, next) {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api != null && next != null && next.isNotEmpty) {
|
||||
api.updateAuthToken(next);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/// Navigation helper provider - determines where user should go
|
||||
final authNavigationStateProvider = Provider<AuthNavigationState>((ref) {
|
||||
final authState = ref.watch(authStateManagerProvider);
|
||||
|
||||
switch (authState.status) {
|
||||
case AuthStatus.initial:
|
||||
case AuthStatus.loading:
|
||||
return AuthNavigationState.loading;
|
||||
case AuthStatus.authenticated:
|
||||
return AuthNavigationState.authenticated;
|
||||
case AuthStatus.unauthenticated:
|
||||
case AuthStatus.tokenExpired:
|
||||
return AuthNavigationState.needsLogin;
|
||||
case AuthStatus.error:
|
||||
return AuthNavigationState.error;
|
||||
}
|
||||
});
|
||||
|
||||
enum AuthNavigationState { loading, authenticated, needsLogin, error }
|
||||
659
lib/features/auth/views/connect_signin_page.dart
Normal file
659
lib/features/auth/views/connect_signin_page.dart
Normal file
@@ -0,0 +1,659 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../core/models/server_config.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../../core/services/api_service.dart';
|
||||
import '../../../core/services/input_validation_service.dart';
|
||||
import '../../../core/widgets/error_boundary.dart';
|
||||
import '../../../shared/services/brand_service.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import '../../../shared/widgets/conduit_components.dart';
|
||||
import '../../../core/auth/auth_state_manager.dart';
|
||||
import '../../chat/views/chat_page.dart';
|
||||
|
||||
class ConnectAndSignInPage extends ConsumerStatefulWidget {
|
||||
const ConnectAndSignInPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConnectAndSignInPage> createState() =>
|
||||
_ConnectAndSignInPageState();
|
||||
}
|
||||
|
||||
class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Server controls
|
||||
final TextEditingController _urlController = TextEditingController();
|
||||
String? _connectionError;
|
||||
|
||||
// Auth controls
|
||||
final TextEditingController _usernameController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
String? _loginError;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_prefillFromState();
|
||||
_loadSavedCredentials();
|
||||
}
|
||||
|
||||
Future<void> _prefillFromState() async {
|
||||
final activeServer = await ref.read(activeServerProvider.future);
|
||||
if (activeServer != null) {
|
||||
_urlController.text = activeServer.url;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSavedCredentials() async {
|
||||
final storage = ref.read(optimizedStorageServiceProvider);
|
||||
final savedCredentials = await storage.getSavedCredentials();
|
||||
if (savedCredentials != null) {
|
||||
setState(() {
|
||||
_usernameController.text = savedCredentials['username'] ?? '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<bool> _connectToServer() async {
|
||||
if (!_formKey.currentState!.validate()) return false;
|
||||
|
||||
setState(() {
|
||||
_connectionError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
String url = _urlController.text.trim();
|
||||
if (url.isEmpty) throw Exception('URL cannot be empty');
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'http://$url';
|
||||
}
|
||||
if (url.endsWith('/')) {
|
||||
url = url.substring(0, url.length - 1);
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
|
||||
throw Exception('Invalid URL format. Please check your input.');
|
||||
}
|
||||
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||
throw Exception('Only HTTP and HTTPS protocols are supported.');
|
||||
}
|
||||
|
||||
final tempConfig = ServerConfig(
|
||||
id: const Uuid().v4(),
|
||||
name: _deriveServerNameFromUrl(url),
|
||||
url: url,
|
||||
isActive: true,
|
||||
);
|
||||
|
||||
final api = ApiService(serverConfig: tempConfig);
|
||||
final isHealthy = await api.checkHealth();
|
||||
if (!isHealthy) {
|
||||
throw Exception('This does not appear to be an Open-WebUI server.');
|
||||
}
|
||||
|
||||
await _saveServerConfig(tempConfig);
|
||||
// Success
|
||||
return true;
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_connectionError = _formatConnectionError(e.toString());
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveServerConfig(ServerConfig config) async {
|
||||
final storage = ref.read(optimizedStorageServiceProvider);
|
||||
await storage.saveServerConfigs([config]);
|
||||
await storage.setActiveServerId(config.id);
|
||||
ref.invalidate(serverConfigsProvider);
|
||||
ref.invalidate(activeServerProvider);
|
||||
}
|
||||
|
||||
String _deriveServerNameFromUrl(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (uri.host.isNotEmpty) return uri.host;
|
||||
} catch (_) {}
|
||||
return 'Server';
|
||||
}
|
||||
|
||||
Future<void> _signIn() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() {
|
||||
_loginError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final authManager = ref.read(authStateManagerProvider.notifier);
|
||||
final success = await authManager.login(
|
||||
_usernameController.text.trim(),
|
||||
_passwordController.text,
|
||||
rememberCredentials: true,
|
||||
);
|
||||
if (!success) {
|
||||
final authState = ref.read(authStateManagerProvider);
|
||||
throw Exception(authState.error ?? 'Login failed');
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_loginError = _formatLoginError(e.toString());
|
||||
});
|
||||
} finally {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectAndSignIn() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_connectionError = null;
|
||||
_loginError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final connected = await _connectToServer();
|
||||
if (!connected) return;
|
||||
// Wait for providers to reflect the new active server and API service
|
||||
await ref.read(activeServerProvider.future);
|
||||
final apiReady = await _waitForApiService();
|
||||
if (!apiReady) {
|
||||
setState(() {
|
||||
_connectionError = 'Setting up the connection... Please try again.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
await _signIn();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _waitForApiService({
|
||||
Duration timeout = const Duration(seconds: 2),
|
||||
}) async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api != null) return true;
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
return ref.read(apiServiceProvider) != null;
|
||||
}
|
||||
|
||||
String _formatConnectionError(String error) {
|
||||
if (error.contains('SocketException')) {
|
||||
return 'We couldn\'t reach the server. Check your connection and that the server is running.';
|
||||
} else if (error.contains('timeout')) {
|
||||
return 'Connection timed out. The server might be busy or blocked by a firewall.';
|
||||
} else if (error.contains('Invalid URL format')) {
|
||||
return error.replaceFirst('Exception: ', '');
|
||||
} else if (error.contains('Missing protocol')) {
|
||||
return 'Include http:// or https:// (e.g., http://192.168.1.10:3000).';
|
||||
} else if (error.contains('Only HTTP and HTTPS')) {
|
||||
return 'Use http:// or https:// only.';
|
||||
}
|
||||
return 'Couldn\'t connect. Double-check the address and try again.';
|
||||
}
|
||||
|
||||
String _formatLoginError(String error) {
|
||||
if (error.contains('401') || error.contains('Unauthorized')) {
|
||||
return 'Invalid username or password. Please try again.';
|
||||
} else if (error.contains('redirect')) {
|
||||
return 'The server is redirecting requests. Check your server\'s HTTPS configuration.';
|
||||
} else if (error.contains('SocketException')) {
|
||||
return 'Unable to connect to server. Please check your connection.';
|
||||
} else if (error.contains('timeout')) {
|
||||
return 'The request timed out. Please try again.';
|
||||
}
|
||||
return 'We couldn\'t sign you in. Check your credentials and server settings.';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isIOS = Platform.isIOS;
|
||||
final activeServerAsync = ref.watch(activeServerProvider);
|
||||
final reviewerMode = ref.watch(reviewerModeProvider);
|
||||
|
||||
return ErrorBoundary(
|
||||
child: Scaffold(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(Spacing.pagePadding),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onLongPress: () async {
|
||||
HapticFeedback.mediumImpact();
|
||||
await ref
|
||||
.read(reviewerModeProvider.notifier)
|
||||
.toggle();
|
||||
if (!mounted) return;
|
||||
final enabled = ref.read(reviewerModeProvider);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
enabled
|
||||
? 'Reviewer Mode enabled: Demo without server'
|
||||
: 'Reviewer Mode disabled',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
BrandService.createBrandIcon(
|
||||
size: 100,
|
||||
useGradient: true,
|
||||
addShadow: true,
|
||||
),
|
||||
if (reviewerMode)
|
||||
Positioned(
|
||||
bottom: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.warning
|
||||
.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.warning,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Reviewer Mode',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.warning,
|
||||
fontSize: AppTypography.labelSmall,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.animate()
|
||||
.scale(
|
||||
duration: AnimationDuration.pageTransition,
|
||||
curve: Curves.easeOutBack,
|
||||
)
|
||||
.then()
|
||||
.shimmer(duration: AnimationDuration.typingIndicator),
|
||||
|
||||
const SizedBox(height: Spacing.sectionGap),
|
||||
|
||||
Text(
|
||||
'Connect and sign in',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.conduitTheme.headingLarge?.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
).animate().fadeIn(
|
||||
duration: AnimationDuration.pageTransition,
|
||||
delay: AnimationDuration.microInteraction,
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.comfortable),
|
||||
|
||||
if (reviewerMode) ...[
|
||||
ConduitButton(
|
||||
text: 'Enter Reviewer Demo',
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ChatPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
isSecondary: true,
|
||||
isFullWidth: true,
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Text(
|
||||
'Demo mode: explore the app without a server. Some features are simulated.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.bodySmall,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.sectionGap),
|
||||
],
|
||||
|
||||
// Card container for form content
|
||||
ConduitCard(
|
||||
isElevated: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Step 1: Server
|
||||
_SectionHeader(
|
||||
icon: isIOS
|
||||
? CupertinoIcons.globe
|
||||
: Icons.language,
|
||||
title: 'Server',
|
||||
subtitle: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
AccessibleFormField(
|
||||
label: 'Server address',
|
||||
hint: 'https://server',
|
||||
controller: _urlController,
|
||||
validator: InputValidationService.combine([
|
||||
InputValidationService.validateRequired,
|
||||
(value) => InputValidationService.validateUrl(
|
||||
value,
|
||||
required: true,
|
||||
),
|
||||
]),
|
||||
keyboardType: TextInputType.url,
|
||||
semanticLabel:
|
||||
'Enter your server URL or IP address',
|
||||
onSubmitted: (_) => _connectAndSignIn(),
|
||||
prefixIcon: Icon(
|
||||
isIOS ? CupertinoIcons.globe : Icons.public,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
).animate().slideX(
|
||||
begin: -0.08,
|
||||
duration: AnimationDuration.messageSlide,
|
||||
delay: AnimationDuration.microInteraction,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
|
||||
if (_connectionError != null) ...[
|
||||
const SizedBox(height: Spacing.sm),
|
||||
_InlineMessage(
|
||||
message: _connectionError!,
|
||||
isError: true,
|
||||
).animate().slideX(
|
||||
begin: 0.08,
|
||||
duration: AnimationDuration.messageSlide,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: Spacing.sectionGap),
|
||||
|
||||
// Step 2: Sign in
|
||||
_SectionHeader(
|
||||
icon: isIOS
|
||||
? CupertinoIcons.lock
|
||||
: Icons.lock_outline,
|
||||
title: 'Sign in',
|
||||
subtitle: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
activeServerAsync.maybeWhen(
|
||||
data: (server) => server != null
|
||||
? Row(
|
||||
children: [
|
||||
Icon(
|
||||
isIOS
|
||||
? CupertinoIcons.link
|
||||
: Icons.link_outlined,
|
||||
size: IconSize.small,
|
||||
color: context
|
||||
.conduitTheme
|
||||
.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
server.url,
|
||||
textAlign: TextAlign.left,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context
|
||||
.conduitTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: context
|
||||
.conduitTheme
|
||||
.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
AccessibleFormField(
|
||||
label: 'Username or email',
|
||||
hint: null,
|
||||
controller: _usernameController,
|
||||
validator: InputValidationService.combine([
|
||||
InputValidationService.validateRequired,
|
||||
(value) =>
|
||||
InputValidationService.validateEmailOrUsername(
|
||||
value,
|
||||
),
|
||||
]),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
semanticLabel: 'Enter your username or email',
|
||||
prefixIcon: Icon(
|
||||
isIOS
|
||||
? CupertinoIcons.person
|
||||
: Icons.person_outline,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.comfortable),
|
||||
|
||||
AccessibleFormField(
|
||||
label: 'Password',
|
||||
hint: null,
|
||||
controller: _passwordController,
|
||||
validator: InputValidationService.combine([
|
||||
InputValidationService.validateRequired,
|
||||
(value) =>
|
||||
InputValidationService.validateMinLength(
|
||||
value,
|
||||
1,
|
||||
fieldName: 'Password',
|
||||
),
|
||||
]),
|
||||
obscureText: _obscurePassword,
|
||||
semanticLabel: 'Enter your password',
|
||||
prefixIcon: Icon(
|
||||
isIOS
|
||||
? CupertinoIcons.lock
|
||||
: Icons.lock_outline,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? (isIOS
|
||||
? CupertinoIcons.eye_slash
|
||||
: Icons.visibility_off)
|
||||
: (isIOS
|
||||
? CupertinoIcons.eye
|
||||
: Icons.visibility),
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
onPressed: () => setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
}),
|
||||
),
|
||||
onSubmitted: (_) => _connectAndSignIn(),
|
||||
),
|
||||
|
||||
if (_loginError != null) ...[
|
||||
const SizedBox(height: Spacing.sm),
|
||||
_InlineMessage(
|
||||
message: _loginError!,
|
||||
isError: true,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: Spacing.md),
|
||||
|
||||
ConduitButton(
|
||||
text: 'Continue',
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: _connectAndSignIn,
|
||||
isLoading: _isSubmitting,
|
||||
isFullWidth: true,
|
||||
).animate().scale(
|
||||
duration: AnimationDuration.buttonPress,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
const _SectionHeader({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: context.conduitTheme.iconPrimary),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: context.conduitTheme.headingSmall?.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle!,
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InlineMessage extends StatelessWidget {
|
||||
final String message;
|
||||
final bool isError;
|
||||
|
||||
const _InlineMessage({required this.message, this.isError = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isIOS = Platform.isIOS;
|
||||
final color = isError
|
||||
? context.conduitTheme.error
|
||||
: context.conduitTheme.success;
|
||||
final bg = isError
|
||||
? context.conduitTheme.errorBackground
|
||||
: context.conduitTheme.successBackground;
|
||||
final icon = isError
|
||||
? (isIOS
|
||||
? CupertinoIcons.exclamationmark_circle_fill
|
||||
: Icons.error_outline)
|
||||
: (isIOS ? CupertinoIcons.check_mark_circled : Icons.check_circle);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(Spacing.cardPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.card),
|
||||
border: Border.all(
|
||||
color: color.withValues(alpha: 0.3),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
boxShadow: ConduitShadows.low,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: IconSize.medium),
|
||||
const SizedBox(width: Spacing.comfortable),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: context.conduitTheme.bodyMedium?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// removed unused _ButtonProgress; ConduitButton provides built-in loading state
|
||||
1425
lib/features/chat/providers/chat_providers.dart
Normal file
1425
lib/features/chat/providers/chat_providers.dart
Normal file
File diff suppressed because it is too large
Load Diff
397
lib/features/chat/services/conversation_search_service.dart
Normal file
397
lib/features/chat/services/conversation_search_service.dart
Normal file
@@ -0,0 +1,397 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/conversation.dart';
|
||||
import '../../../core/models/chat_message.dart';
|
||||
|
||||
/// Advanced conversation search service with multiple search strategies
|
||||
class ConversationSearchService {
|
||||
static const int maxResults = 50;
|
||||
static const int contextLines = 2; // Lines before/after match for context
|
||||
|
||||
/// Search through conversations with various criteria
|
||||
Future<ConversationSearchResults> searchConversations({
|
||||
required List<Conversation> conversations,
|
||||
required String query,
|
||||
ConversationSearchOptions options = const ConversationSearchOptions(),
|
||||
}) async {
|
||||
if (query.trim().isEmpty) {
|
||||
return ConversationSearchResults.empty();
|
||||
}
|
||||
|
||||
final normalizedQuery = query.toLowerCase().trim();
|
||||
final results = <ConversationSearchMatch>[];
|
||||
|
||||
// Search through each conversation
|
||||
for (final conversation in conversations) {
|
||||
final matches = await _searchInConversation(
|
||||
conversation: conversation,
|
||||
query: normalizedQuery,
|
||||
options: options,
|
||||
);
|
||||
results.addAll(matches);
|
||||
}
|
||||
|
||||
// Sort results by relevance and date
|
||||
results.sort((a, b) {
|
||||
// First by relevance score (higher is better)
|
||||
final relevanceCompare = b.relevanceScore.compareTo(a.relevanceScore);
|
||||
if (relevanceCompare != 0) return relevanceCompare;
|
||||
|
||||
// Then by date (newer first)
|
||||
return b.timestamp.compareTo(a.timestamp);
|
||||
});
|
||||
|
||||
// Limit results
|
||||
final limitedResults = results.take(maxResults).toList();
|
||||
|
||||
return ConversationSearchResults(
|
||||
query: query,
|
||||
results: limitedResults,
|
||||
totalMatches: results.length,
|
||||
searchDuration: DateTime.now().difference(DateTime.now()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Search within a single conversation
|
||||
Future<List<ConversationSearchMatch>> _searchInConversation({
|
||||
required Conversation conversation,
|
||||
required String query,
|
||||
required ConversationSearchOptions options,
|
||||
}) async {
|
||||
final matches = <ConversationSearchMatch>[];
|
||||
|
||||
// Search in conversation title
|
||||
if (options.searchTitles && _containsQuery(conversation.title, query)) {
|
||||
matches.add(
|
||||
ConversationSearchMatch(
|
||||
conversationId: conversation.id,
|
||||
conversationTitle: conversation.title,
|
||||
matchType: SearchMatchType.title,
|
||||
snippet: conversation.title,
|
||||
highlightedSnippet: _highlightQuery(conversation.title, query),
|
||||
relevanceScore: _calculateTitleRelevance(conversation.title, query),
|
||||
timestamp: conversation.updatedAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Search in messages
|
||||
if (options.searchMessages) {
|
||||
final messageMatches = await _searchInMessages(
|
||||
conversation: conversation,
|
||||
query: query,
|
||||
options: options,
|
||||
);
|
||||
matches.addAll(messageMatches);
|
||||
}
|
||||
|
||||
// Search in tags
|
||||
if (options.searchTags) {
|
||||
for (final tag in conversation.tags) {
|
||||
if (_containsQuery(tag, query)) {
|
||||
matches.add(
|
||||
ConversationSearchMatch(
|
||||
conversationId: conversation.id,
|
||||
conversationTitle: conversation.title,
|
||||
matchType: SearchMatchType.tag,
|
||||
snippet: tag,
|
||||
highlightedSnippet: _highlightQuery(tag, query),
|
||||
relevanceScore: _calculateTagRelevance(tag, query),
|
||||
timestamp: conversation.updatedAt,
|
||||
additionalInfo: {'tag': tag},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// Search within messages of a conversation
|
||||
Future<List<ConversationSearchMatch>> _searchInMessages({
|
||||
required Conversation conversation,
|
||||
required String query,
|
||||
required ConversationSearchOptions options,
|
||||
}) async {
|
||||
final matches = <ConversationSearchMatch>[];
|
||||
|
||||
for (int i = 0; i < conversation.messages.length; i++) {
|
||||
final message = conversation.messages[i];
|
||||
|
||||
// Skip system messages if not enabled
|
||||
if (!options.includeSystemMessages && message.role == 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by role if specified
|
||||
if (options.roleFilter != null && message.role != options.roleFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if message contains query
|
||||
if (_containsQuery(message.content, query)) {
|
||||
final snippet = _extractSnippet(message.content, query);
|
||||
final contextMessages = _getContextMessages(conversation.messages, i);
|
||||
|
||||
matches.add(
|
||||
ConversationSearchMatch(
|
||||
conversationId: conversation.id,
|
||||
conversationTitle: conversation.title,
|
||||
messageId: message.id,
|
||||
matchType: SearchMatchType.message,
|
||||
snippet: snippet,
|
||||
highlightedSnippet: _highlightQuery(snippet, query),
|
||||
relevanceScore: _calculateMessageRelevance(message.content, query),
|
||||
timestamp: message.timestamp,
|
||||
messageRole: message.role,
|
||||
messageIndex: i,
|
||||
contextMessages: contextMessages,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// Extract relevant snippet around the query match
|
||||
String _extractSnippet(String content, String query) {
|
||||
const maxSnippetLength = 200;
|
||||
final queryIndex = content.toLowerCase().indexOf(query);
|
||||
|
||||
if (queryIndex == -1) {
|
||||
return content.substring(0, maxSnippetLength.clamp(0, content.length));
|
||||
}
|
||||
|
||||
// Calculate snippet bounds
|
||||
final start = (queryIndex - 50).clamp(0, content.length);
|
||||
final end = (queryIndex + query.length + 50).clamp(0, content.length);
|
||||
|
||||
String snippet = content.substring(start, end);
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (start > 0) snippet = '...$snippet';
|
||||
if (end < content.length) snippet = '$snippet...';
|
||||
|
||||
return snippet;
|
||||
}
|
||||
|
||||
/// Get context messages around a matched message
|
||||
List<ChatMessage> _getContextMessages(List<ChatMessage> messages, int index) {
|
||||
final start = (index - contextLines).clamp(0, messages.length);
|
||||
final end = (index + contextLines + 1).clamp(0, messages.length);
|
||||
return messages.sublist(start, end);
|
||||
}
|
||||
|
||||
/// Highlight query matches in text
|
||||
String _highlightQuery(String text, String query) {
|
||||
if (query.isEmpty) return text;
|
||||
|
||||
final regex = RegExp(RegExp.escape(query), caseSensitive: false);
|
||||
return text.replaceAllMapped(regex, (match) {
|
||||
return '<mark>${match.group(0)}</mark>';
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if text contains the query
|
||||
bool _containsQuery(String text, String query) {
|
||||
return text.toLowerCase().contains(query);
|
||||
}
|
||||
|
||||
/// Calculate relevance score for title matches
|
||||
double _calculateTitleRelevance(String title, String query) {
|
||||
final titleLower = title.toLowerCase();
|
||||
final queryLower = query.toLowerCase();
|
||||
|
||||
// Exact match gets highest score
|
||||
if (titleLower == queryLower) return 100.0;
|
||||
|
||||
// Title starts with query gets high score
|
||||
if (titleLower.startsWith(queryLower)) return 90.0;
|
||||
|
||||
// Title contains query as whole word gets medium score
|
||||
if (RegExp(
|
||||
r'\b' + RegExp.escape(queryLower) + r'\b',
|
||||
).hasMatch(titleLower)) {
|
||||
return 70.0;
|
||||
}
|
||||
|
||||
// Partial match gets lower score
|
||||
return 50.0;
|
||||
}
|
||||
|
||||
/// Calculate relevance score for message matches
|
||||
double _calculateMessageRelevance(String content, String query) {
|
||||
final contentLower = content.toLowerCase();
|
||||
final queryLower = query.toLowerCase();
|
||||
|
||||
// Count occurrences
|
||||
final occurrences = queryLower.allMatches(contentLower).length;
|
||||
|
||||
// Base score for containing the query
|
||||
double score = 30.0;
|
||||
|
||||
// Bonus for multiple occurrences
|
||||
score += (occurrences - 1) * 10.0;
|
||||
|
||||
// Bonus for whole word matches
|
||||
if (RegExp(
|
||||
r'\b' + RegExp.escape(queryLower) + r'\b',
|
||||
).hasMatch(contentLower)) {
|
||||
score += 20.0;
|
||||
}
|
||||
|
||||
// Penalty for very long messages (relevance dilution)
|
||||
if (content.length > 1000) {
|
||||
score *= 0.8;
|
||||
}
|
||||
|
||||
return score.clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
/// Calculate relevance score for tag matches
|
||||
double _calculateTagRelevance(String tag, String query) {
|
||||
final tagLower = tag.toLowerCase();
|
||||
final queryLower = query.toLowerCase();
|
||||
|
||||
// Exact match gets highest score
|
||||
if (tagLower == queryLower) return 80.0;
|
||||
|
||||
// Tag starts with query gets high score
|
||||
if (tagLower.startsWith(queryLower)) return 70.0;
|
||||
|
||||
// Partial match gets medium score
|
||||
return 50.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Search options for conversation search
|
||||
@immutable
|
||||
class ConversationSearchOptions {
|
||||
final bool searchTitles;
|
||||
final bool searchMessages;
|
||||
final bool searchTags;
|
||||
final bool includeSystemMessages;
|
||||
final String? roleFilter; // 'user', 'assistant', 'system'
|
||||
final DateTime? dateFrom;
|
||||
final DateTime? dateTo;
|
||||
final bool caseSensitive;
|
||||
|
||||
const ConversationSearchOptions({
|
||||
this.searchTitles = true,
|
||||
this.searchMessages = true,
|
||||
this.searchTags = true,
|
||||
this.includeSystemMessages = false,
|
||||
this.roleFilter,
|
||||
this.dateFrom,
|
||||
this.dateTo,
|
||||
this.caseSensitive = false,
|
||||
});
|
||||
|
||||
ConversationSearchOptions copyWith({
|
||||
bool? searchTitles,
|
||||
bool? searchMessages,
|
||||
bool? searchTags,
|
||||
bool? includeSystemMessages,
|
||||
String? roleFilter,
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
bool? caseSensitive,
|
||||
}) {
|
||||
return ConversationSearchOptions(
|
||||
searchTitles: searchTitles ?? this.searchTitles,
|
||||
searchMessages: searchMessages ?? this.searchMessages,
|
||||
searchTags: searchTags ?? this.searchTags,
|
||||
includeSystemMessages:
|
||||
includeSystemMessages ?? this.includeSystemMessages,
|
||||
roleFilter: roleFilter ?? this.roleFilter,
|
||||
dateFrom: dateFrom ?? this.dateFrom,
|
||||
dateTo: dateTo ?? this.dateTo,
|
||||
caseSensitive: caseSensitive ?? this.caseSensitive,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Search results container
|
||||
@immutable
|
||||
class ConversationSearchResults {
|
||||
final String query;
|
||||
final List<ConversationSearchMatch> results;
|
||||
final int totalMatches;
|
||||
final Duration searchDuration;
|
||||
|
||||
const ConversationSearchResults({
|
||||
required this.query,
|
||||
required this.results,
|
||||
required this.totalMatches,
|
||||
required this.searchDuration,
|
||||
});
|
||||
|
||||
factory ConversationSearchResults.empty() {
|
||||
return ConversationSearchResults(
|
||||
query: '',
|
||||
results: const [],
|
||||
totalMatches: 0,
|
||||
searchDuration: Duration.zero,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isEmpty => results.isEmpty;
|
||||
bool get isNotEmpty => results.isNotEmpty;
|
||||
int get length => results.length;
|
||||
}
|
||||
|
||||
/// Individual search match
|
||||
@immutable
|
||||
class ConversationSearchMatch {
|
||||
final String conversationId;
|
||||
final String conversationTitle;
|
||||
final String? messageId;
|
||||
final SearchMatchType matchType;
|
||||
final String snippet;
|
||||
final String highlightedSnippet;
|
||||
final double relevanceScore;
|
||||
final DateTime timestamp;
|
||||
final String? messageRole;
|
||||
final int? messageIndex;
|
||||
final List<ChatMessage>? contextMessages;
|
||||
final Map<String, dynamic>? additionalInfo;
|
||||
|
||||
const ConversationSearchMatch({
|
||||
required this.conversationId,
|
||||
required this.conversationTitle,
|
||||
this.messageId,
|
||||
required this.matchType,
|
||||
required this.snippet,
|
||||
required this.highlightedSnippet,
|
||||
required this.relevanceScore,
|
||||
required this.timestamp,
|
||||
this.messageRole,
|
||||
this.messageIndex,
|
||||
this.contextMessages,
|
||||
this.additionalInfo,
|
||||
});
|
||||
}
|
||||
|
||||
/// Types of search matches
|
||||
enum SearchMatchType { title, message, tag }
|
||||
|
||||
/// Provider for conversation search service
|
||||
final conversationSearchServiceProvider = Provider<ConversationSearchService>((
|
||||
ref,
|
||||
) {
|
||||
return ConversationSearchService();
|
||||
});
|
||||
|
||||
/// Provider for search results
|
||||
final conversationSearchResultsProvider =
|
||||
StateProvider<ConversationSearchResults?>((ref) {
|
||||
return null;
|
||||
});
|
||||
|
||||
/// Provider for search options
|
||||
final searchOptionsProvider = StateProvider<ConversationSearchOptions>((ref) {
|
||||
return const ConversationSearchOptions();
|
||||
});
|
||||
433
lib/features/chat/services/file_attachment_service.dart
Normal file
433
lib/features/chat/services/file_attachment_service.dart
Normal file
@@ -0,0 +1,433 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import '../../../core/services/api_service.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
|
||||
class FileAttachmentService {
|
||||
final ApiService _apiService;
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
|
||||
FileAttachmentService(this._apiService);
|
||||
|
||||
// Pick files from device
|
||||
Future<List<File>> pickFiles({
|
||||
bool allowMultiple = true,
|
||||
List<String>? allowedExtensions,
|
||||
}) async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: allowMultiple,
|
||||
type: allowedExtensions != null ? FileType.custom : FileType.any,
|
||||
allowedExtensions: allowedExtensions,
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.files
|
||||
.where((file) => file.path != null)
|
||||
.map((file) => File(file.path!))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to pick files: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Pick image from gallery
|
||||
Future<File?> pickImage() async {
|
||||
try {
|
||||
final XFile? image = await _imagePicker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image == null) return null;
|
||||
return File(image.path);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to pick image: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Take photo from camera
|
||||
Future<File?> takePhoto() async {
|
||||
try {
|
||||
final XFile? photo = await _imagePicker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (photo == null) return null;
|
||||
return File(photo.path);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to take photo: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Compress image similar to OpenWebUI's implementation
|
||||
Future<String> compressImage(
|
||||
String imageDataUrl,
|
||||
int? maxWidth,
|
||||
int? maxHeight,
|
||||
) async {
|
||||
try {
|
||||
// Decode base64 data
|
||||
final data = imageDataUrl.split(',')[1];
|
||||
final bytes = base64Decode(data);
|
||||
|
||||
// Decode image
|
||||
final codec = await ui.instantiateImageCodec(bytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
final image = frame.image;
|
||||
|
||||
int width = image.width;
|
||||
int height = image.height;
|
||||
|
||||
// Calculate new dimensions maintaining aspect ratio
|
||||
if (maxWidth != null && maxHeight != null) {
|
||||
if (width <= maxWidth && height <= maxHeight) {
|
||||
return imageDataUrl; // No compression needed
|
||||
}
|
||||
|
||||
if (width / height > maxWidth / maxHeight) {
|
||||
height = ((maxWidth * height) / width).round();
|
||||
width = maxWidth;
|
||||
} else {
|
||||
width = ((maxHeight * width) / height).round();
|
||||
height = maxHeight;
|
||||
}
|
||||
} else if (maxWidth != null) {
|
||||
if (width <= maxWidth) {
|
||||
return imageDataUrl; // No compression needed
|
||||
}
|
||||
height = ((maxWidth * height) / width).round();
|
||||
width = maxWidth;
|
||||
} else if (maxHeight != null) {
|
||||
if (height <= maxHeight) {
|
||||
return imageDataUrl; // No compression needed
|
||||
}
|
||||
width = ((maxHeight * width) / height).round();
|
||||
height = maxHeight;
|
||||
}
|
||||
|
||||
// Create compressed image
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
|
||||
canvas.drawImageRect(
|
||||
image,
|
||||
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
|
||||
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
|
||||
Paint(),
|
||||
);
|
||||
|
||||
final picture = recorder.endRecording();
|
||||
final compressedImage = await picture.toImage(width, height);
|
||||
final byteData = await compressedImage.toByteData(
|
||||
format: ui.ImageByteFormat.png,
|
||||
);
|
||||
final compressedBytes = byteData!.buffer.asUint8List();
|
||||
|
||||
// Convert back to data URL
|
||||
final compressedBase64 = base64Encode(compressedBytes);
|
||||
return 'data:image/png;base64,$compressedBase64';
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Image compression failed: $e');
|
||||
return imageDataUrl; // Return original if compression fails
|
||||
}
|
||||
}
|
||||
|
||||
// Convert image file to base64 data URL with compression
|
||||
Future<String?> convertImageToDataUrl(
|
||||
File imageFile, {
|
||||
bool enableCompression = false,
|
||||
int? maxWidth,
|
||||
int? maxHeight,
|
||||
}) async {
|
||||
try {
|
||||
debugPrint('DEBUG: Converting image to data URL: ${imageFile.path}');
|
||||
|
||||
// Read the file as bytes
|
||||
final bytes = await imageFile.readAsBytes();
|
||||
|
||||
// Determine MIME type based on file extension
|
||||
final ext = path.extension(imageFile.path).toLowerCase();
|
||||
String mimeType = 'image/png'; // default
|
||||
|
||||
if (ext == '.jpg' || ext == '.jpeg') {
|
||||
mimeType = 'image/jpeg';
|
||||
} else if (ext == '.gif') {
|
||||
mimeType = 'image/gif';
|
||||
} else if (ext == '.webp') {
|
||||
mimeType = 'image/webp';
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
final base64String = base64Encode(bytes);
|
||||
String dataUrl = 'data:$mimeType;base64,$base64String';
|
||||
|
||||
// Apply compression if enabled
|
||||
if (enableCompression && (maxWidth != null || maxHeight != null)) {
|
||||
dataUrl = await compressImage(dataUrl, maxWidth, maxHeight);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: Image converted to data URL with MIME type: $mimeType',
|
||||
);
|
||||
return dataUrl;
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Failed to convert image to data URL: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload file with progress tracking
|
||||
Stream<FileUploadState> uploadFile(File file) async* {
|
||||
debugPrint('DEBUG: Starting file upload for: ${file.path}');
|
||||
try {
|
||||
final fileName = path.basename(file.path);
|
||||
final fileSize = await file.length();
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: File details - Name: $fileName, Size: $fileSize bytes',
|
||||
);
|
||||
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.uploading,
|
||||
);
|
||||
|
||||
// Check if this is an image file
|
||||
final ext = path.extension(fileName).toLowerCase();
|
||||
final isImage = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
].contains(ext.substring(1));
|
||||
|
||||
if (isImage) {
|
||||
debugPrint(
|
||||
'DEBUG: Image file detected, converting to data URL instead of uploading',
|
||||
);
|
||||
|
||||
// For images, convert to data URL instead of uploading
|
||||
final dataUrl = await convertImageToDataUrl(file);
|
||||
if (dataUrl != null) {
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 1.0,
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: dataUrl, // Use data URL as fileId for images
|
||||
isImage: true,
|
||||
);
|
||||
} else {
|
||||
throw Exception('Failed to convert image to data URL');
|
||||
}
|
||||
} else {
|
||||
debugPrint('DEBUG: Non-image file, uploading to server...');
|
||||
// Upload file using the API service
|
||||
final fileId = await _apiService.uploadFile(file.path, fileName);
|
||||
debugPrint('DEBUG: File uploaded successfully with ID: $fileId');
|
||||
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 1.0,
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: fileId,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: File upload failed: $e');
|
||||
final fileName = path.basename(file.path);
|
||||
final fileSize = await file.length();
|
||||
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.failed,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload multiple files
|
||||
Stream<List<FileUploadState>> uploadMultipleFiles(List<File> files) async* {
|
||||
final states = <String, FileUploadState>{};
|
||||
|
||||
for (final file in files) {
|
||||
final uploadStream = uploadFile(file);
|
||||
await for (final state in uploadStream) {
|
||||
states[file.path] = state;
|
||||
yield states.values.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format file size for display
|
||||
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';
|
||||
}
|
||||
|
||||
// Get file icon based on extension
|
||||
String getFileIcon(String fileName) {
|
||||
final ext = path.extension(fileName).toLowerCase();
|
||||
|
||||
// Documents
|
||||
if (['.pdf', '.doc', '.docx'].contains(ext)) return '📄';
|
||||
if (['.xls', '.xlsx'].contains(ext)) return '📊';
|
||||
if (['.ppt', '.pptx'].contains(ext)) return '📊';
|
||||
|
||||
// Images
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext)) return '🖼️';
|
||||
|
||||
// Code
|
||||
if (['.js', '.ts', '.py', '.dart', '.java', '.cpp'].contains(ext)) {
|
||||
return '💻';
|
||||
}
|
||||
if (['.html', '.css', '.json', '.xml'].contains(ext)) return '🌐';
|
||||
|
||||
// Archives
|
||||
if (['.zip', '.rar', '.7z', '.tar', '.gz'].contains(ext)) return '📦';
|
||||
|
||||
// Media
|
||||
if (['.mp3', '.wav', '.flac', '.m4a'].contains(ext)) return '🎵';
|
||||
if (['.mp4', '.avi', '.mov', '.mkv'].contains(ext)) return '🎬';
|
||||
|
||||
return '📎';
|
||||
}
|
||||
}
|
||||
|
||||
// File upload state
|
||||
class FileUploadState {
|
||||
final File file;
|
||||
final String fileName;
|
||||
final int fileSize;
|
||||
final double progress;
|
||||
final FileUploadStatus status;
|
||||
final String? fileId;
|
||||
final String? error;
|
||||
final bool? isImage; // Added for image files
|
||||
|
||||
FileUploadState({
|
||||
required this.file,
|
||||
required this.fileName,
|
||||
required this.fileSize,
|
||||
required this.progress,
|
||||
required this.status,
|
||||
this.fileId,
|
||||
this.error,
|
||||
this.isImage, // Added for image files
|
||||
});
|
||||
|
||||
String get formattedSize {
|
||||
if (fileSize < 1024) return '$fileSize B';
|
||||
if (fileSize < 1024 * 1024) {
|
||||
return '${(fileSize / 1024).toStringAsFixed(1)} KB';
|
||||
}
|
||||
if (fileSize < 1024 * 1024 * 1024) {
|
||||
return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(fileSize / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
|
||||
String get fileIcon {
|
||||
final ext = path.extension(fileName).toLowerCase();
|
||||
|
||||
// Documents
|
||||
if (['.pdf', '.doc', '.docx'].contains(ext)) return '📄';
|
||||
if (['.xls', '.xlsx'].contains(ext)) return '📊';
|
||||
if (['.ppt', '.pptx'].contains(ext)) return '📊';
|
||||
|
||||
// Images
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext)) return '🖼️';
|
||||
|
||||
// Code
|
||||
if (['.js', '.ts', '.py', '.dart', '.java', '.cpp'].contains(ext)) {
|
||||
return '💻';
|
||||
}
|
||||
if (['.html', '.css', '.json', '.xml'].contains(ext)) return '🌐';
|
||||
|
||||
// Archives
|
||||
if (['.zip', '.rar', '.7z', '.tar', '.gz'].contains(ext)) return '📦';
|
||||
|
||||
// Media
|
||||
if (['.mp3', '.wav', '.flac', '.m4a'].contains(ext)) return '🎵';
|
||||
if (['.mp4', '.avi', '.mov', '.mkv'].contains(ext)) return '🎬';
|
||||
|
||||
return '📎';
|
||||
}
|
||||
}
|
||||
|
||||
enum FileUploadStatus { pending, uploading, completed, failed }
|
||||
|
||||
// Providers
|
||||
final fileAttachmentServiceProvider = Provider<FileAttachmentService?>((ref) {
|
||||
final apiService = ref.watch(apiServiceProvider);
|
||||
if (apiService == null) return null;
|
||||
return FileAttachmentService(apiService);
|
||||
});
|
||||
|
||||
// State notifier for managing attached files
|
||||
class AttachedFilesNotifier extends StateNotifier<List<FileUploadState>> {
|
||||
AttachedFilesNotifier() : super([]);
|
||||
|
||||
void addFiles(List<File> files) {
|
||||
final newStates = files
|
||||
.map(
|
||||
(file) => FileUploadState(
|
||||
file: file,
|
||||
fileName: path.basename(file.path),
|
||||
fileSize: file.lengthSync(),
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.pending,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
state = [...state, ...newStates];
|
||||
}
|
||||
|
||||
void updateFileState(String filePath, FileUploadState newState) {
|
||||
state = [
|
||||
for (final fileState in state)
|
||||
if (fileState.file.path == filePath) newState else fileState,
|
||||
];
|
||||
}
|
||||
|
||||
void removeFile(String filePath) {
|
||||
state = state
|
||||
.where((fileState) => fileState.file.path != filePath)
|
||||
.toList();
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
state = [];
|
||||
}
|
||||
}
|
||||
|
||||
final attachedFilesProvider =
|
||||
StateNotifierProvider<AttachedFilesNotifier, List<FileUploadState>>((ref) {
|
||||
return AttachedFilesNotifier();
|
||||
});
|
||||
538
lib/features/chat/services/message_batch_service.dart
Normal file
538
lib/features/chat/services/message_batch_service.dart
Normal file
@@ -0,0 +1,538 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/chat_message.dart';
|
||||
import '../../../core/models/conversation.dart';
|
||||
|
||||
/// Service for managing batch operations on messages
|
||||
class MessageBatchService {
|
||||
/// Export messages to various formats
|
||||
Future<BatchOperationResult> exportMessages({
|
||||
required List<ChatMessage> messages,
|
||||
required ExportFormat format,
|
||||
ExportOptions? options,
|
||||
}) async {
|
||||
try {
|
||||
final exportOptions = options ?? const ExportOptions();
|
||||
String content;
|
||||
|
||||
switch (format) {
|
||||
case ExportFormat.text:
|
||||
content = _exportToText(messages, exportOptions);
|
||||
break;
|
||||
case ExportFormat.markdown:
|
||||
content = _exportToMarkdown(messages, exportOptions);
|
||||
break;
|
||||
case ExportFormat.json:
|
||||
content = _exportToJson(messages, exportOptions);
|
||||
break;
|
||||
case ExportFormat.csv:
|
||||
content = _exportToCsv(messages, exportOptions);
|
||||
break;
|
||||
}
|
||||
|
||||
return BatchOperationResult.success(
|
||||
operation: BatchOperation.export,
|
||||
data: {'content': content, 'format': format.name},
|
||||
affectedCount: messages.length,
|
||||
);
|
||||
} catch (e) {
|
||||
return BatchOperationResult.error(
|
||||
operation: BatchOperation.export,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete multiple messages
|
||||
Future<BatchOperationResult> deleteMessages({
|
||||
required List<String> messageIds,
|
||||
required Conversation conversation,
|
||||
}) async {
|
||||
try {
|
||||
final updatedMessages = conversation.messages
|
||||
.where((message) => !messageIds.contains(message.id))
|
||||
.toList();
|
||||
|
||||
final updatedConversation = conversation.copyWith(
|
||||
messages: updatedMessages,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
return BatchOperationResult.success(
|
||||
operation: BatchOperation.delete,
|
||||
data: {'conversation': updatedConversation},
|
||||
affectedCount: messageIds.length,
|
||||
);
|
||||
} catch (e) {
|
||||
return BatchOperationResult.error(
|
||||
operation: BatchOperation.delete,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy messages to clipboard or another conversation
|
||||
Future<BatchOperationResult> copyMessages({
|
||||
required List<ChatMessage> messages,
|
||||
String? targetConversationId,
|
||||
CopyFormat? format,
|
||||
}) async {
|
||||
try {
|
||||
final copyFormat = format ?? CopyFormat.markdown;
|
||||
String content;
|
||||
|
||||
switch (copyFormat) {
|
||||
case CopyFormat.plain:
|
||||
content = messages.map((m) => m.content).join('\n\n');
|
||||
break;
|
||||
case CopyFormat.markdown:
|
||||
content = _exportToMarkdown(messages, const ExportOptions());
|
||||
break;
|
||||
case CopyFormat.json:
|
||||
content = _exportToJson(messages, const ExportOptions());
|
||||
break;
|
||||
}
|
||||
|
||||
return BatchOperationResult.success(
|
||||
operation: BatchOperation.copy,
|
||||
data: {
|
||||
'content': content,
|
||||
'format': copyFormat.name,
|
||||
'targetConversation': targetConversationId,
|
||||
},
|
||||
affectedCount: messages.length,
|
||||
);
|
||||
} catch (e) {
|
||||
return BatchOperationResult.error(
|
||||
operation: BatchOperation.copy,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move messages to another conversation
|
||||
Future<BatchOperationResult> moveMessages({
|
||||
required List<String> messageIds,
|
||||
required Conversation sourceConversation,
|
||||
required Conversation targetConversation,
|
||||
}) async {
|
||||
try {
|
||||
final messagesToMove = sourceConversation.messages
|
||||
.where((message) => messageIds.contains(message.id))
|
||||
.toList();
|
||||
|
||||
final updatedSourceMessages = sourceConversation.messages
|
||||
.where((message) => !messageIds.contains(message.id))
|
||||
.toList();
|
||||
|
||||
final updatedTargetMessages = [
|
||||
...targetConversation.messages,
|
||||
...messagesToMove,
|
||||
];
|
||||
|
||||
final updatedSourceConversation = sourceConversation.copyWith(
|
||||
messages: updatedSourceMessages,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final updatedTargetConversation = targetConversation.copyWith(
|
||||
messages: updatedTargetMessages,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
return BatchOperationResult.success(
|
||||
operation: BatchOperation.move,
|
||||
data: {
|
||||
'sourceConversation': updatedSourceConversation,
|
||||
'targetConversation': updatedTargetConversation,
|
||||
},
|
||||
affectedCount: messageIds.length,
|
||||
);
|
||||
} catch (e) {
|
||||
return BatchOperationResult.error(
|
||||
operation: BatchOperation.move,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Archive multiple messages
|
||||
Future<BatchOperationResult> archiveMessages({
|
||||
required List<String> messageIds,
|
||||
required Conversation conversation,
|
||||
}) async {
|
||||
try {
|
||||
final updatedMessages = conversation.messages.map((message) {
|
||||
if (messageIds.contains(message.id)) {
|
||||
return message.copyWith(
|
||||
metadata: {
|
||||
...?message.metadata,
|
||||
'archived': true,
|
||||
'archivedAt': DateTime.now().toIso8601String(),
|
||||
},
|
||||
);
|
||||
}
|
||||
return message;
|
||||
}).toList();
|
||||
|
||||
final updatedConversation = conversation.copyWith(
|
||||
messages: updatedMessages,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
return BatchOperationResult.success(
|
||||
operation: BatchOperation.archive,
|
||||
data: {'conversation': updatedConversation},
|
||||
affectedCount: messageIds.length,
|
||||
);
|
||||
} catch (e) {
|
||||
return BatchOperationResult.error(
|
||||
operation: BatchOperation.archive,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add tags to multiple messages
|
||||
Future<BatchOperationResult> tagMessages({
|
||||
required List<String> messageIds,
|
||||
required List<String> tags,
|
||||
required Conversation conversation,
|
||||
}) async {
|
||||
try {
|
||||
final updatedMessages = conversation.messages.map((message) {
|
||||
if (messageIds.contains(message.id)) {
|
||||
final existingTags =
|
||||
(message.metadata?['tags'] as List<String>?) ?? <String>[];
|
||||
final newTags = <String>{...existingTags, ...tags}.toList();
|
||||
|
||||
return message.copyWith(
|
||||
metadata: {...?message.metadata, 'tags': newTags},
|
||||
);
|
||||
}
|
||||
return message;
|
||||
}).toList();
|
||||
|
||||
final updatedConversation = conversation.copyWith(
|
||||
messages: updatedMessages,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
return BatchOperationResult.success(
|
||||
operation: BatchOperation.tag,
|
||||
data: {'conversation': updatedConversation},
|
||||
affectedCount: messageIds.length,
|
||||
);
|
||||
} catch (e) {
|
||||
return BatchOperationResult.error(
|
||||
operation: BatchOperation.tag,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter messages by criteria
|
||||
List<ChatMessage> filterMessages({
|
||||
required List<ChatMessage> messages,
|
||||
MessageFilter? filter,
|
||||
}) {
|
||||
if (filter == null) return messages;
|
||||
|
||||
return messages.where((message) {
|
||||
// Role filter
|
||||
if (filter.roles.isNotEmpty && !filter.roles.contains(message.role)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if (filter.dateFrom != null &&
|
||||
message.timestamp.isBefore(filter.dateFrom!)) {
|
||||
return false;
|
||||
}
|
||||
if (filter.dateTo != null && message.timestamp.isAfter(filter.dateTo!)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Content filter
|
||||
if (filter.contentFilter != null &&
|
||||
!message.content.toLowerCase().contains(
|
||||
filter.contentFilter!.toLowerCase(),
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tag filter
|
||||
if (filter.tags.isNotEmpty) {
|
||||
final messageTags = (message.metadata?['tags'] as List<String>?) ?? [];
|
||||
if (!filter.tags.any((tag) => messageTags.contains(tag))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Has attachments filter
|
||||
if (filter.hasAttachments != null) {
|
||||
final hasAttachments = message.attachmentIds?.isNotEmpty ?? false;
|
||||
if (filter.hasAttachments! != hasAttachments) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Export format implementations
|
||||
String _exportToText(List<ChatMessage> messages, ExportOptions options) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
if (options.includeMetadata) {
|
||||
buffer.writeln('Exported on: ${DateTime.now().toIso8601String()}');
|
||||
buffer.writeln('Messages: ${messages.length}');
|
||||
buffer.writeln('${'=' * 50}\n');
|
||||
}
|
||||
|
||||
for (final message in messages) {
|
||||
if (options.includeTimestamps) {
|
||||
buffer.writeln('[${message.timestamp.toIso8601String()}]');
|
||||
}
|
||||
|
||||
buffer.writeln('${_formatRole(message.role)}: ${message.content}');
|
||||
|
||||
if (options.includeMetadata && message.metadata?.isNotEmpty == true) {
|
||||
buffer.writeln('Metadata: ${message.metadata}');
|
||||
}
|
||||
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _exportToMarkdown(List<ChatMessage> messages, ExportOptions options) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
if (options.includeMetadata) {
|
||||
buffer.writeln('# Conversation Export\n');
|
||||
buffer.writeln('- **Exported on:** ${DateTime.now().toIso8601String()}');
|
||||
buffer.writeln('- **Messages:** ${messages.length}\n');
|
||||
buffer.writeln('---\n');
|
||||
}
|
||||
|
||||
for (final message in messages) {
|
||||
buffer.writeln('## ${_formatRole(message.role)}');
|
||||
|
||||
if (options.includeTimestamps) {
|
||||
buffer.writeln('*${message.timestamp.toIso8601String()}*\n');
|
||||
}
|
||||
|
||||
buffer.writeln(message.content);
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _exportToJson(List<ChatMessage> messages, ExportOptions options) {
|
||||
final data = {
|
||||
if (options.includeMetadata) ...{
|
||||
'exportedAt': DateTime.now().toIso8601String(),
|
||||
'messageCount': messages.length,
|
||||
},
|
||||
'messages': messages
|
||||
.map(
|
||||
(message) => {
|
||||
'id': message.id,
|
||||
'role': message.role,
|
||||
'content': message.content,
|
||||
if (options.includeTimestamps)
|
||||
'timestamp': message.timestamp.toIso8601String(),
|
||||
if (message.model != null) 'model': message.model,
|
||||
if (message.attachmentIds?.isNotEmpty == true)
|
||||
'attachmentIds': message.attachmentIds,
|
||||
if (options.includeMetadata &&
|
||||
message.metadata?.isNotEmpty == true)
|
||||
'metadata': message.metadata,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
|
||||
return JsonEncoder.withIndent(' ').convert(data);
|
||||
}
|
||||
|
||||
String _exportToCsv(List<ChatMessage> messages, ExportOptions options) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
// Header
|
||||
final headers = ['Role', 'Content'];
|
||||
if (options.includeTimestamps) headers.insert(1, 'Timestamp');
|
||||
if (options.includeMetadata) headers.add('Metadata');
|
||||
|
||||
buffer.writeln(headers.map(_escapeCsv).join(','));
|
||||
|
||||
// Data rows
|
||||
for (final message in messages) {
|
||||
final row = <String>[
|
||||
message.role,
|
||||
message.content.replaceAll('\n', '\\n'),
|
||||
];
|
||||
|
||||
if (options.includeTimestamps) {
|
||||
row.insert(1, message.timestamp.toIso8601String());
|
||||
}
|
||||
|
||||
if (options.includeMetadata) {
|
||||
row.add(message.metadata?.toString() ?? '');
|
||||
}
|
||||
|
||||
buffer.writeln(row.map(_escapeCsv).join(','));
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _formatRole(String role) {
|
||||
switch (role.toLowerCase()) {
|
||||
case 'user':
|
||||
return 'User';
|
||||
case 'assistant':
|
||||
return 'Assistant';
|
||||
case 'system':
|
||||
return 'System';
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
String _escapeCsv(String value) {
|
||||
if (value.contains(',') || value.contains('"') || value.contains('\n')) {
|
||||
return '"${value.replaceAll('"', '""')}"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Export formats supported by the batch service
|
||||
enum ExportFormat { text, markdown, json, csv }
|
||||
|
||||
/// Copy formats for clipboard operations
|
||||
enum CopyFormat { plain, markdown, json }
|
||||
|
||||
/// Batch operations that can be performed
|
||||
enum BatchOperation { export, delete, copy, move, archive, tag }
|
||||
|
||||
/// Options for export operations
|
||||
@immutable
|
||||
class ExportOptions {
|
||||
final bool includeTimestamps;
|
||||
final bool includeMetadata;
|
||||
final bool includeAttachments;
|
||||
|
||||
const ExportOptions({
|
||||
this.includeTimestamps = true,
|
||||
this.includeMetadata = false,
|
||||
this.includeAttachments = true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Filter criteria for messages
|
||||
@immutable
|
||||
class MessageFilter {
|
||||
final List<String> roles;
|
||||
final DateTime? dateFrom;
|
||||
final DateTime? dateTo;
|
||||
final String? contentFilter;
|
||||
final List<String> tags;
|
||||
final bool? hasAttachments;
|
||||
|
||||
const MessageFilter({
|
||||
this.roles = const [],
|
||||
this.dateFrom,
|
||||
this.dateTo,
|
||||
this.contentFilter,
|
||||
this.tags = const [],
|
||||
this.hasAttachments,
|
||||
});
|
||||
|
||||
MessageFilter copyWith({
|
||||
List<String>? roles,
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
String? contentFilter,
|
||||
List<String>? tags,
|
||||
bool? hasAttachments,
|
||||
}) {
|
||||
return MessageFilter(
|
||||
roles: roles ?? this.roles,
|
||||
dateFrom: dateFrom ?? this.dateFrom,
|
||||
dateTo: dateTo ?? this.dateTo,
|
||||
contentFilter: contentFilter ?? this.contentFilter,
|
||||
tags: tags ?? this.tags,
|
||||
hasAttachments: hasAttachments ?? this.hasAttachments,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a batch operation
|
||||
@immutable
|
||||
class BatchOperationResult {
|
||||
final BatchOperation operation;
|
||||
final bool success;
|
||||
final String? error;
|
||||
final Map<String, dynamic>? data;
|
||||
final int affectedCount;
|
||||
|
||||
const BatchOperationResult({
|
||||
required this.operation,
|
||||
required this.success,
|
||||
this.error,
|
||||
this.data,
|
||||
this.affectedCount = 0,
|
||||
});
|
||||
|
||||
factory BatchOperationResult.success({
|
||||
required BatchOperation operation,
|
||||
Map<String, dynamic>? data,
|
||||
int affectedCount = 0,
|
||||
}) {
|
||||
return BatchOperationResult(
|
||||
operation: operation,
|
||||
success: true,
|
||||
data: data,
|
||||
affectedCount: affectedCount,
|
||||
);
|
||||
}
|
||||
|
||||
factory BatchOperationResult.error({
|
||||
required BatchOperation operation,
|
||||
required String error,
|
||||
}) {
|
||||
return BatchOperationResult(
|
||||
operation: operation,
|
||||
success: false,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for message batch service
|
||||
final messageBatchServiceProvider = Provider<MessageBatchService>((ref) {
|
||||
return MessageBatchService();
|
||||
});
|
||||
|
||||
/// Provider for selected messages (for batch operations)
|
||||
final selectedMessagesProvider = StateProvider<Set<String>>((ref) {
|
||||
return <String>{};
|
||||
});
|
||||
|
||||
/// Provider for batch operation mode
|
||||
final batchModeProvider = StateProvider<bool>((ref) {
|
||||
return false;
|
||||
});
|
||||
|
||||
/// Provider for message filter
|
||||
final messageFilterProvider = StateProvider<MessageFilter?>((ref) {
|
||||
return null;
|
||||
});
|
||||
220
lib/features/chat/services/voice_input_service.dart
Normal file
220
lib/features/chat/services/voice_input_service.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:record/record.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class VoiceInputService {
|
||||
final AudioRecorder _recorder = AudioRecorder();
|
||||
bool _isInitialized = false;
|
||||
bool _isListening = false;
|
||||
StreamController<String>? _textStreamController;
|
||||
String _currentText = '';
|
||||
// Public stream for UI waveform visualization (emits partial text length as proxy)
|
||||
StreamController<int>? _intensityController;
|
||||
Stream<int> get intensityStream =>
|
||||
_intensityController?.stream ?? const Stream<int>.empty();
|
||||
Timer? _autoStopTimer;
|
||||
StreamSubscription<Amplitude>? _ampSub;
|
||||
|
||||
bool get isSupportedPlatform => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
Future<bool> initialize() async {
|
||||
if (_isInitialized) return true;
|
||||
if (!isSupportedPlatform) return false;
|
||||
// Log platform for diagnostics
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'DEBUG: VoiceInputService initialize on platform: '
|
||||
'${Platform.isAndroid
|
||||
? 'Android'
|
||||
: Platform.isIOS
|
||||
? 'iOS'
|
||||
: 'Other'}',
|
||||
);
|
||||
_isInitialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> checkPermissions() async {
|
||||
try {
|
||||
return await _recorder.hasPermission();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool get isListening => _isListening;
|
||||
bool get isAvailable => _isInitialized;
|
||||
|
||||
Stream<String> startListening() {
|
||||
// Ensure initialized; we allow initialize to pass even if native STT unavailable
|
||||
if (!_isInitialized) {
|
||||
throw Exception('Voice input not initialized');
|
||||
}
|
||||
|
||||
if (_isListening) {
|
||||
stopListening();
|
||||
}
|
||||
|
||||
_textStreamController = StreamController<String>.broadcast();
|
||||
_currentText = '';
|
||||
_isListening = true;
|
||||
|
||||
_intensityController = StreamController<int>.broadcast();
|
||||
|
||||
// Start recording raw audio; UI or auto-timer will stop and trigger transcription via API
|
||||
// ignore: avoid_print
|
||||
print('DEBUG: VoiceInputService startListening');
|
||||
_startRecordingProxyIntensity();
|
||||
|
||||
// Auto-stop after 30 seconds similar to native STT behavior
|
||||
_autoStopTimer?.cancel();
|
||||
_autoStopTimer = Timer(const Duration(seconds: 30), () {
|
||||
if (_isListening) {
|
||||
_stopListening();
|
||||
}
|
||||
});
|
||||
|
||||
return _textStreamController!.stream;
|
||||
}
|
||||
|
||||
Future<void> stopListening() async {
|
||||
await _stopListening();
|
||||
}
|
||||
|
||||
Future<void> _stopListening() async {
|
||||
if (!_isListening) return;
|
||||
|
||||
_isListening = false;
|
||||
// Also stop recorder if active
|
||||
await _stopRecording();
|
||||
// ignore: avoid_print
|
||||
print('DEBUG: VoiceInputService stopped listening');
|
||||
|
||||
_autoStopTimer?.cancel();
|
||||
_autoStopTimer = null;
|
||||
_ampSub?.cancel();
|
||||
_ampSub = null;
|
||||
|
||||
if (_currentText.isNotEmpty) {
|
||||
_textStreamController?.add(_currentText);
|
||||
}
|
||||
|
||||
_textStreamController?.close();
|
||||
_textStreamController = null;
|
||||
_intensityController?.close();
|
||||
_intensityController = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
stopListening();
|
||||
_stopRecording(force: true);
|
||||
}
|
||||
|
||||
// --- Recording and intensity proxy for server transcription path ---
|
||||
Future<void> _startRecordingProxyIntensity() async {
|
||||
try {
|
||||
final hasMic = await _recorder.hasPermission();
|
||||
if (!hasMic) {
|
||||
_textStreamController?.addError('Microphone permission not granted');
|
||||
_stopListening();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start recording in a portable format (WAV/PCM) for best compatibility with server
|
||||
final tmpDir = await getTemporaryDirectory();
|
||||
final filePath = p.join(
|
||||
tmpDir.path,
|
||||
'conduit_voice_${DateTime.now().millisecondsSinceEpoch}.wav',
|
||||
);
|
||||
await _recorder.start(
|
||||
const RecordConfig(
|
||||
encoder: AudioEncoder.wav,
|
||||
numChannels: 1,
|
||||
sampleRate: 16000,
|
||||
bitRate: 128000,
|
||||
),
|
||||
path: filePath,
|
||||
);
|
||||
// ignore: avoid_print
|
||||
print('DEBUG: VoiceInputService recording started at: ' + filePath);
|
||||
|
||||
// Drive intensity from amplitude stream and detect silence
|
||||
// Consider amplitude less than threshold as silence; stop after ~3s of continuous silence
|
||||
const silenceThresholdDb = -45.0; // dBFS threshold
|
||||
const silenceWindow = Duration(seconds: 3);
|
||||
DateTime lastNonSilent = DateTime.now();
|
||||
|
||||
_ampSub = _recorder
|
||||
.onAmplitudeChanged(const Duration(milliseconds: 125))
|
||||
.listen((amp) {
|
||||
if (!_isListening) return;
|
||||
// Normalize peak power (dBFS) into 0-10 bar scale
|
||||
final db = amp.current;
|
||||
// Map dB [-60..0] -> [0..10]
|
||||
final clamped = db.clamp(-60.0, 0.0);
|
||||
final norm = ((clamped + 60.0) / 60.0) * 10.0;
|
||||
_intensityController?.add(norm.round().clamp(0, 10));
|
||||
|
||||
if (db > silenceThresholdDb) {
|
||||
lastNonSilent = DateTime.now();
|
||||
} else {
|
||||
if (DateTime.now().difference(lastNonSilent) >= silenceWindow) {
|
||||
_stopListening();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('DEBUG: VoiceInputService recording failed: $e');
|
||||
_textStreamController?.addError('Audio recording failed: $e');
|
||||
_stopListening();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopRecording({bool force = false}) async {
|
||||
try {
|
||||
if (!await _recorder.isRecording() && !force) return;
|
||||
final path = await _recorder.stop();
|
||||
if (path == null) {
|
||||
_textStreamController?.addError('Recording failed: no file path');
|
||||
return;
|
||||
}
|
||||
// ignore: avoid_print
|
||||
print('DEBUG: VoiceInputService recording saved: ' + path);
|
||||
// Hand off recorded file path to listeners as a special token; UI layer will upload for transcription
|
||||
_textStreamController?.add('[[AUDIO_FILE_PATH]]:$path');
|
||||
} catch (e) {
|
||||
_textStreamController?.addError('Stop recording error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Native locales not used in server transcription mode
|
||||
}
|
||||
|
||||
final voiceInputServiceProvider = Provider<VoiceInputService>((ref) {
|
||||
return VoiceInputService();
|
||||
});
|
||||
|
||||
final voiceInputAvailableProvider = FutureProvider<bool>((ref) async {
|
||||
final service = ref.watch(voiceInputServiceProvider);
|
||||
if (!service.isSupportedPlatform) return false;
|
||||
final initialized = await service.initialize();
|
||||
if (!initialized) return false;
|
||||
final hasPermission = await service.checkPermissions();
|
||||
if (!hasPermission) return false;
|
||||
return service.isAvailable;
|
||||
});
|
||||
|
||||
final voiceInputStreamProvider = StreamProvider<String>((ref) {
|
||||
// Voice input stream would be initialized when needed
|
||||
return const Stream.empty();
|
||||
});
|
||||
|
||||
/// Stream of crude voice intensity for waveform visuals
|
||||
final voiceIntensityStreamProvider = StreamProvider<int>((ref) {
|
||||
// Connected at runtime by the UI after calling startListening
|
||||
return const Stream.empty();
|
||||
});
|
||||
2474
lib/features/chat/views/chat_page.dart
Normal file
2474
lib/features/chat/views/chat_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
68
lib/features/chat/views/chat_page_helpers.dart
Normal file
68
lib/features/chat/views/chat_page_helpers.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
|
||||
class PressableScale extends StatefulWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
const PressableScale({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onTap,
|
||||
this.borderRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PressableScale> createState() => _PressableScaleState();
|
||||
}
|
||||
|
||||
class _PressableScaleState extends State<PressableScale>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scale;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: AnimationDuration.fast,
|
||||
lowerBound: 0.0,
|
||||
upperBound: 1.0,
|
||||
);
|
||||
_scale = Tween<double>(begin: 1.0, end: 0.98).animate(
|
||||
CurvedAnimation(parent: _controller, curve: AnimationCurves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTapDown(TapDownDetails _) => _controller.forward();
|
||||
void _onTapUp(TapUpDetails _) => _controller.reverse();
|
||||
void _onTapCancel() => _controller.reverse();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTapDown: _onTapDown,
|
||||
onTapUp: _onTapUp,
|
||||
onTapCancel: _onTapCancel,
|
||||
onTap: widget.onTap,
|
||||
child: ScaleTransition(
|
||||
scale: _scale,
|
||||
child: ClipRRect(
|
||||
borderRadius:
|
||||
widget.borderRadius ??
|
||||
BorderRadius.circular(AppBorderRadius.card),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
370
lib/features/chat/views/conversation_search_page.dart
Normal file
370
lib/features/chat/views/conversation_search_page.dart
Normal file
@@ -0,0 +1,370 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../../../shared/utils/platform_utils.dart';
|
||||
import '../widgets/conversation_search_widget.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../providers/chat_providers.dart';
|
||||
import 'chat_page.dart';
|
||||
|
||||
/// Dedicated page for conversation search functionality
|
||||
class ConversationSearchPage extends ConsumerStatefulWidget {
|
||||
const ConversationSearchPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConversationSearchPage> createState() =>
|
||||
_ConversationSearchPageState();
|
||||
}
|
||||
|
||||
class _ConversationSearchPageState
|
||||
extends ConsumerState<ConversationSearchPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final conduitTheme = context.conduitTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
appBar: _buildAppBar(context, conduitTheme),
|
||||
body: ConversationSearchWidget(
|
||||
onResultTap: _onSearchResultTap,
|
||||
showFilters: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar(
|
||||
BuildContext context,
|
||||
ConduitThemeExtension theme,
|
||||
) {
|
||||
if (Platform.isIOS) {
|
||||
return CupertinoNavigationBar(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
border: Border(bottom: BorderSide(color: theme.cardBorder, width: 0.5)),
|
||||
leading: CupertinoNavigationBarBackButton(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
middle: Text(
|
||||
'Search Conversations',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AppBar(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
elevation: Elevation.none,
|
||||
title: Text(
|
||||
'Search Conversations',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.headlineMedium,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: context.conduitTheme.textPrimary),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1),
|
||||
child: Container(height: 1, color: theme.cardBorder),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onSearchResultTap(String conversationId, String? messageId) {
|
||||
PlatformUtils.lightHaptic();
|
||||
|
||||
// Set the active conversation
|
||||
final conversationsAsync = ref.read(conversationsProvider);
|
||||
conversationsAsync.whenData((conversations) {
|
||||
final conversation = conversations.firstWhere(
|
||||
(c) => c.id == conversationId,
|
||||
orElse: () => throw Exception('Conversation not found'),
|
||||
);
|
||||
|
||||
// Set active conversation
|
||||
ref.read(activeConversationProvider.notifier).state = conversation;
|
||||
|
||||
// Navigate back to chat
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// If we have a specific message, navigate to it and highlight it
|
||||
if (messageId != null) {
|
||||
// Use a custom navigation approach with message highlighting
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
ChatPageWithHighlight(messageIdToHighlight: messageId),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat page wrapper that highlights a specific message
|
||||
class ChatPageWithHighlight extends ConsumerStatefulWidget {
|
||||
final String messageIdToHighlight;
|
||||
|
||||
const ChatPageWithHighlight({super.key, required this.messageIdToHighlight});
|
||||
|
||||
@override
|
||||
ConsumerState<ChatPageWithHighlight> createState() =>
|
||||
_ChatPageWithHighlightState();
|
||||
}
|
||||
|
||||
class _ChatPageWithHighlightState extends ConsumerState<ChatPageWithHighlight> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Schedule highlighting after the widget is built
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToAndHighlightMessage();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToAndHighlightMessage() async {
|
||||
try {
|
||||
final messages = ref.read(chatMessagesProvider);
|
||||
final messageIndex = messages.indexWhere(
|
||||
(msg) => msg.id == widget.messageIdToHighlight,
|
||||
);
|
||||
|
||||
if (messageIndex >= 0 && _scrollController.hasClients) {
|
||||
// Calculate the approximate position (assuming 100px per message)
|
||||
final targetOffset = messageIndex * 100.0;
|
||||
|
||||
// Scroll to the message
|
||||
await _scrollController.animateTo(
|
||||
targetOffset,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
// Show a highlight indicator
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Found message'),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: context.conduitTheme.buttonPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Message not found'),
|
||||
backgroundColor: context.conduitTheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const ChatPage();
|
||||
}
|
||||
}
|
||||
|
||||
/// Search icon button for app bars
|
||||
class ConversationSearchButton extends ConsumerWidget {
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const ConversationSearchButton({super.key, this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
Platform.isIOS ? CupertinoIcons.search : Icons.search,
|
||||
color: context.conduitTheme.iconPrimary.withValues(alpha: 0.8),
|
||||
size: IconSize.lg,
|
||||
),
|
||||
onPressed:
|
||||
onPressed ??
|
||||
() {
|
||||
PlatformUtils.lightHaptic();
|
||||
Navigator.of(context).push(
|
||||
Platform.isIOS
|
||||
? CupertinoPageRoute(
|
||||
builder: (context) => const ConversationSearchPage(),
|
||||
)
|
||||
: MaterialPageRoute(
|
||||
builder: (context) => const ConversationSearchPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'Search conversations',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick search overlay that can be shown from any page
|
||||
class QuickSearchOverlay extends ConsumerStatefulWidget {
|
||||
final VoidCallback? onDismiss;
|
||||
|
||||
const QuickSearchOverlay({super.key, this.onDismiss});
|
||||
|
||||
@override
|
||||
ConsumerState<QuickSearchOverlay> createState() => _QuickSearchOverlayState();
|
||||
}
|
||||
|
||||
class _QuickSearchOverlayState extends ConsumerState<QuickSearchOverlay>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
_slideAnimation =
|
||||
Tween<Offset>(begin: const Offset(0, -1), end: Offset.zero).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _dismiss() async {
|
||||
await _animationController.reverse();
|
||||
widget.onDismiss?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Backdrop
|
||||
GestureDetector(
|
||||
onTap: _dismiss,
|
||||
child: Container(
|
||||
color: context.conduitTheme.surfaceBackground.withValues(
|
||||
alpha: 0.7 * _fadeAnimation.value,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Search panel
|
||||
SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).size.height * 0.8,
|
||||
margin: const EdgeInsets.only(top: Spacing.xxxl + Spacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(AppBorderRadius.lg),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Handle bar
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: Spacing.sm),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.xs,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Search content
|
||||
Expanded(
|
||||
child: ConversationSearchWidget(
|
||||
onResultTap: (conversationId, messageId) {
|
||||
_onSearchResultTap(conversationId, messageId);
|
||||
_dismiss();
|
||||
},
|
||||
showFilters: false, // Simplified for overlay
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onSearchResultTap(String conversationId, String? messageId) {
|
||||
// Same logic as the search page
|
||||
final conversationsAsync = ref.read(conversationsProvider);
|
||||
conversationsAsync.whenData((conversations) {
|
||||
final conversation = conversations.firstWhere(
|
||||
(c) => c.id == conversationId,
|
||||
orElse: () => throw Exception('Conversation not found'),
|
||||
);
|
||||
|
||||
ref.read(activeConversationProvider.notifier).state = conversation;
|
||||
|
||||
if (messageId != null) {
|
||||
debugPrint(
|
||||
'Navigate to message: $messageId in conversation: $conversationId',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Show quick search overlay
|
||||
void showQuickSearch(BuildContext context) {
|
||||
showGeneralDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
barrierDismissible: true,
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
pageBuilder: (context, animation, secondaryAnimation) {
|
||||
return QuickSearchOverlay(onDismiss: () => Navigator.of(context).pop());
|
||||
},
|
||||
);
|
||||
}
|
||||
465
lib/features/chat/views/model_selector_page.dart
Normal file
465
lib/features/chat/views/model_selector_page.dart
Normal file
@@ -0,0 +1,465 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../../../core/models/model.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
|
||||
class ModelSelectorPage extends ConsumerStatefulWidget {
|
||||
const ModelSelectorPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ModelSelectorPage> createState() => _ModelSelectorPageState();
|
||||
}
|
||||
|
||||
class _ModelSelectorPageState extends ConsumerState<ModelSelectorPage> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.removeListener(_onSearchChanged);
|
||||
_searchController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
setState(() {
|
||||
_searchQuery = _searchController.text;
|
||||
});
|
||||
}
|
||||
|
||||
List<Model> _filterModels(List<Model> models) {
|
||||
if (_searchQuery.isEmpty) {
|
||||
return models;
|
||||
}
|
||||
|
||||
final query = _searchQuery.toLowerCase();
|
||||
return models.where((model) {
|
||||
return model.name.toLowerCase().contains(query) ||
|
||||
(model.description?.toLowerCase().contains(query) ?? false);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final modelsAsync = ref.watch(modelsProvider);
|
||||
final selectedModel = ref.watch(selectedModelProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Select Model'),
|
||||
leading: IconButton(
|
||||
icon: Icon(Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Search bar
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor.withValues(alpha: 0.1),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _buildSearchField(),
|
||||
),
|
||||
// Models list
|
||||
Expanded(
|
||||
child: modelsAsync.when(
|
||||
data: (models) {
|
||||
final filteredModels = _filterModels(models);
|
||||
|
||||
if (models.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.cube_box
|
||||
: Icons.view_in_ar,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
'No models available',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
'Please check your Open-WebUI configuration',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredModels.isEmpty && _searchQuery.isNotEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.search
|
||||
: Icons.search_off,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
'No models found',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
'Try searching with different keywords',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Group models by category if needed
|
||||
final groupedModels = _groupModels(filteredModels);
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: groupedModels.length,
|
||||
itemBuilder: (context, index) {
|
||||
final group = groupedModels[index];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (group.title != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Spacing.md,
|
||||
Spacing.md,
|
||||
Spacing.md,
|
||||
Spacing.sm,
|
||||
),
|
||||
child: Text(
|
||||
group.title!,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
...group.models.map(
|
||||
(model) => ModelTile(
|
||||
model: model,
|
||||
isSelected: selectedModel?.id == model.id,
|
||||
onTap: () {
|
||||
ref.read(selectedModelProvider.notifier).state =
|
||||
model;
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.exclamationmark_triangle
|
||||
: Icons.error_outline,
|
||||
size: 48,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
'Failed to load models',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: Spacing.lg),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => ref.refresh(modelsProvider),
|
||||
icon: Icon(
|
||||
Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh,
|
||||
),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchField() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
context.conduitTheme.inputBackground.withValues(alpha: 0.6),
|
||||
context.conduitTheme.inputBackground.withValues(alpha: 0.3),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.inputBorder.withValues(alpha: 0.3),
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.inputText,
|
||||
fontSize: AppTypography.bodyMedium,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search models...',
|
||||
hintStyle: TextStyle(
|
||||
color: context.conduitTheme.inputPlaceholder.withValues(alpha: 0.8),
|
||||
fontSize: AppTypography.bodyMedium,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Platform.isIOS ? CupertinoIcons.search : Icons.search,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
size: IconSize.md,
|
||||
),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.clear_circled_solid
|
||||
: Icons.clear,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
size: IconSize.md,
|
||||
),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_searchFocusNode.unfocus();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<ModelGroup> _groupModels(List<Model> models) {
|
||||
// For now, just return all models in one group
|
||||
// In the future, we can group by provider, capability, etc.
|
||||
return [ModelGroup(title: null, models: models)];
|
||||
}
|
||||
}
|
||||
|
||||
class ModelGroup {
|
||||
final String? title;
|
||||
final List<Model> models;
|
||||
|
||||
ModelGroup({required this.title, required this.models});
|
||||
}
|
||||
|
||||
class ModelTile extends StatelessWidget {
|
||||
final Model model;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const ModelTile({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
elevation: isSelected ? 2 : 0,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.1)
|
||||
: null,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
side: BorderSide(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.dividerColor.withValues(alpha: 0.3),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
model.name,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: isSelected ? FontWeight.w600 : null,
|
||||
color: isSelected ? theme.colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.checkmark_circle_fill
|
||||
: Icons.check_circle,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (model.description != null) ...[
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Text(
|
||||
model.description!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (model.isMultimodal)
|
||||
_buildCapabilityChip(
|
||||
context,
|
||||
icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image,
|
||||
label: 'Multimodal',
|
||||
color: AppTheme.info,
|
||||
),
|
||||
if (model.supportsStreaming)
|
||||
_buildCapabilityChip(
|
||||
context,
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.bolt
|
||||
: Icons.flash_on,
|
||||
label: 'Streaming',
|
||||
color: AppTheme.warning,
|
||||
),
|
||||
if (model.supportsRAG)
|
||||
_buildCapabilityChip(
|
||||
context,
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.doc_text
|
||||
: Icons.description,
|
||||
label: 'RAG',
|
||||
color: AppTheme.success,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCapabilityChip(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.labelMedium,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
956
lib/features/chat/widgets/conversation_components.dart
Normal file
956
lib/features/chat/widgets/conversation_components.dart
Normal file
@@ -0,0 +1,956 @@
|
||||
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}';
|
||||
}
|
||||
}
|
||||
}
|
||||
738
lib/features/chat/widgets/conversation_search_widget.dart
Normal file
738
lib/features/chat/widgets/conversation_search_widget.dart
Normal file
@@ -0,0 +1,738 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import 'package:flutter/cupertino.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/widgets/loading_states.dart';
|
||||
import '../../../shared/widgets/empty_states.dart';
|
||||
|
||||
import '../../../shared/utils/platform_utils.dart';
|
||||
import '../services/conversation_search_service.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
|
||||
/// Advanced conversation search widget with filters and results
|
||||
class ConversationSearchWidget extends ConsumerStatefulWidget {
|
||||
final Function(String conversationId, String? messageId)? onResultTap;
|
||||
final bool showFilters;
|
||||
|
||||
const ConversationSearchWidget({
|
||||
super.key,
|
||||
this.onResultTap,
|
||||
this.showFilters = true,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConversationSearchWidget> createState() =>
|
||||
_ConversationSearchWidgetState();
|
||||
}
|
||||
|
||||
class _ConversationSearchWidgetState
|
||||
extends ConsumerState<ConversationSearchWidget> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _searchFocus = FocusNode();
|
||||
bool _isSearching = false;
|
||||
bool _showFilters = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.removeListener(_onSearchChanged);
|
||||
_searchController.dispose();
|
||||
_searchFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
final query = _searchController.text.trim();
|
||||
ref.read(searchQueryProvider.notifier).state = query;
|
||||
|
||||
if (query.isNotEmpty) {
|
||||
_performSearch(query);
|
||||
} else {
|
||||
ref.read(conversationSearchResultsProvider.notifier).state = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
if (_isSearching) return;
|
||||
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final searchService = ref.read(conversationSearchServiceProvider);
|
||||
final conversations = ref
|
||||
.read(conversationsProvider)
|
||||
.when(
|
||||
data: (data) => data,
|
||||
loading: () => <dynamic>[],
|
||||
error: (_, _) => <dynamic>[],
|
||||
);
|
||||
|
||||
final options = ref.read(searchOptionsProvider);
|
||||
|
||||
final results = await searchService.searchConversations(
|
||||
conversations: conversations.cast(),
|
||||
query: query,
|
||||
options: options,
|
||||
);
|
||||
|
||||
ref.read(conversationSearchResultsProvider.notifier).state = results;
|
||||
} catch (e) {
|
||||
debugPrint('Search error: $e');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final conduitTheme = context.conduitTheme;
|
||||
final searchResults = ref.watch(conversationSearchResultsProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Search header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: conduitTheme.cardBackground,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: conduitTheme.cardBorder,
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Search input
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: conduitTheme.inputBackground,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
border: Border.all(
|
||||
color: _searchFocus.hasFocus
|
||||
? conduitTheme.inputBorderFocused
|
||||
: conduitTheme.inputBorder,
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocus,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search conversations...',
|
||||
hintStyle: TextStyle(
|
||||
color: context.conduitTheme.inputPlaceholder,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.search
|
||||
: Icons.search,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
size: AppTypography.headlineMedium,
|
||||
),
|
||||
suffixIcon: _isSearching
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
child: ConduitLoading.inline(
|
||||
size: Spacing.md,
|
||||
),
|
||||
)
|
||||
: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.clear
|
||||
: Icons.clear,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
size: AppTypography.headlineMedium,
|
||||
),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.unfocus();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.md,
|
||||
vertical: Spacing.xs,
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.inputText,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
),
|
||||
onSubmitted: (_) => _searchFocus.unfocus(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Filter toggle
|
||||
if (widget.showFilters) ...[
|
||||
const SizedBox(width: Spacing.xs),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
PlatformUtils.lightHaptic();
|
||||
setState(() {
|
||||
_showFilters = !_showFilters;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
width: Spacing.xxl + Spacing.xs,
|
||||
height: Spacing.xxl + Spacing.xs,
|
||||
decoration: BoxDecoration(
|
||||
color: _showFilters
|
||||
? AppTheme.neutral50.withValues(alpha: 0.2)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.md,
|
||||
),
|
||||
border: Border.all(
|
||||
color: _showFilters
|
||||
? AppTheme.neutral50.withValues(alpha: 0.3)
|
||||
: conduitTheme.inputBorder,
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.slider_horizontal_3
|
||||
: Icons.tune,
|
||||
color: AppTheme.neutral50.withValues(alpha: 0.8),
|
||||
size: AppTypography.headlineMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
// Search filters
|
||||
if (_showFilters && widget.showFilters)
|
||||
_buildSearchFilters(conduitTheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Search results
|
||||
Expanded(child: _buildSearchResults(conduitTheme, searchResults)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchFilters(ConduitThemeExtension theme) {
|
||||
final options = ref.watch(searchOptionsProvider);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: Spacing.md),
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.neutral50.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
border: Border.all(color: theme.cardBorder, width: BorderWidth.regular),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Search in:',
|
||||
style: theme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.neutral50.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
|
||||
// Search scope toggles
|
||||
Wrap(
|
||||
spacing: Spacing.md,
|
||||
runSpacing: Spacing.sm,
|
||||
children: [
|
||||
_buildFilterToggle(
|
||||
'Titles',
|
||||
options.searchTitles,
|
||||
(value) =>
|
||||
_updateSearchOptions(options.copyWith(searchTitles: value)),
|
||||
),
|
||||
_buildFilterToggle(
|
||||
'Messages',
|
||||
options.searchMessages,
|
||||
(value) => _updateSearchOptions(
|
||||
options.copyWith(searchMessages: value),
|
||||
),
|
||||
),
|
||||
_buildFilterToggle(
|
||||
'Tags',
|
||||
options.searchTags,
|
||||
(value) =>
|
||||
_updateSearchOptions(options.copyWith(searchTags: value)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.md),
|
||||
|
||||
Text(
|
||||
'Message type:',
|
||||
style: theme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.neutral50.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
|
||||
// Role filter
|
||||
Wrap(
|
||||
spacing: Spacing.md,
|
||||
runSpacing: Spacing.sm,
|
||||
children: [
|
||||
_buildFilterChip(
|
||||
'All',
|
||||
options.roleFilter == null,
|
||||
() => _updateSearchOptions(options.copyWith(roleFilter: null)),
|
||||
),
|
||||
_buildFilterChip(
|
||||
'My messages',
|
||||
options.roleFilter == 'user',
|
||||
() =>
|
||||
_updateSearchOptions(options.copyWith(roleFilter: 'user')),
|
||||
),
|
||||
_buildFilterChip(
|
||||
'AI messages',
|
||||
options.roleFilter == 'assistant',
|
||||
() => _updateSearchOptions(
|
||||
options.copyWith(roleFilter: 'assistant'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
).animate().slideY(
|
||||
begin: -0.5,
|
||||
end: 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterToggle(
|
||||
String label,
|
||||
bool value,
|
||||
Function(bool) onChanged,
|
||||
) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
PlatformUtils.selectionHaptic();
|
||||
onChanged(!value);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: AppTypography.headlineMedium,
|
||||
height: AppTypography.headlineMedium,
|
||||
decoration: BoxDecoration(
|
||||
color: value ? AppTheme.brandPrimary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
border: Border.all(
|
||||
color: value
|
||||
? AppTheme.brandPrimary
|
||||
: AppTheme.neutral50.withValues(alpha: 0.3),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
child: value
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
color: AppTheme.neutral50,
|
||||
size: AppTypography.labelLarge,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: AppTheme.neutral50.withValues(alpha: 0.8),
|
||||
fontSize: AppTypography.labelLarge,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterChip(String label, bool isActive, VoidCallback onTap) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
PlatformUtils.selectionHaptic();
|
||||
onTap();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.xs,
|
||||
vertical: Spacing.xs + Spacing.xxs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? AppTheme.brandPrimary.withValues(alpha: 0.2)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
||||
border: Border.all(
|
||||
color: isActive
|
||||
? AppTheme.brandPrimary
|
||||
: AppTheme.neutral50.withValues(alpha: 0.3),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isActive
|
||||
? AppTheme.brandPrimary
|
||||
: AppTheme.neutral50.withValues(alpha: 0.8),
|
||||
fontSize: AppTypography.labelMedium,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateSearchOptions(ConversationSearchOptions newOptions) {
|
||||
ref.read(searchOptionsProvider.notifier).state = newOptions;
|
||||
|
||||
// Re-search with new options if we have a query
|
||||
final query = _searchController.text.trim();
|
||||
if (query.isNotEmpty) {
|
||||
_performSearch(query);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSearchResults(
|
||||
ConduitThemeExtension theme,
|
||||
ConversationSearchResults? results,
|
||||
) {
|
||||
if (_searchController.text.trim().isEmpty) {
|
||||
return _buildSearchPrompt(theme);
|
||||
}
|
||||
|
||||
if (results == null) {
|
||||
return Center(child: ConduitLoading.primary());
|
||||
}
|
||||
|
||||
if (results.isEmpty) {
|
||||
return SearchEmptyState(
|
||||
query: results.query,
|
||||
onClearSearch: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.unfocus();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Results header
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.md,
|
||||
vertical: Spacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.neutral50.withValues(alpha: 0.05),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.cardBorder,
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${results.length} of ${results.totalMatches} results',
|
||||
style: theme.bodySmall?.copyWith(
|
||||
color: AppTheme.neutral50.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${results.searchDuration.inMilliseconds}ms',
|
||||
style: theme.bodySmall?.copyWith(
|
||||
color: AppTheme.neutral50.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Results list
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: results.length,
|
||||
itemBuilder: (context, index) {
|
||||
final match = results.results[index];
|
||||
return _buildSearchResultItem(theme, match, index);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchPrompt(ConduitThemeExtension theme) {
|
||||
return ConduitEmptyState(
|
||||
title: 'Search your conversations',
|
||||
subtitle: 'Find messages, titles, and tags across all your conversations',
|
||||
icon: Platform.isIOS ? CupertinoIcons.search : Icons.search,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchResultItem(
|
||||
ConduitThemeExtension theme,
|
||||
ConversationSearchMatch match,
|
||||
int index,
|
||||
) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
PlatformUtils.lightHaptic();
|
||||
widget.onResultTap?.call(match.conversationId, match.messageId);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.md,
|
||||
vertical: Spacing.xs,
|
||||
),
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardBackground,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
border: Border.all(
|
||||
color: theme.cardBorder,
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header with conversation title and match type
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
match.conversationTitle,
|
||||
style: theme.headingSmall?.copyWith(
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
_buildMatchTypeBadge(match.matchType),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
// Snippet with highlighted text
|
||||
_buildHighlightedSnippet(theme, match.highlightedSnippet),
|
||||
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
// Footer with metadata
|
||||
Row(
|
||||
children: [
|
||||
if (match.messageRole != null) ...[
|
||||
_buildRoleBadge(match.messageRole!),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
],
|
||||
Text(
|
||||
_formatTimestamp(match.timestamp),
|
||||
style: theme.caption,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${match.relevanceScore.round()}% match',
|
||||
style: theme.caption?.copyWith(
|
||||
color: AppTheme.brandPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.animate(delay: Duration(milliseconds: index * 50))
|
||||
.fadeIn(duration: const Duration(milliseconds: 200))
|
||||
.slideX(begin: 0.3, end: 0);
|
||||
}
|
||||
|
||||
Widget _buildMatchTypeBadge(SearchMatchType type) {
|
||||
Color color;
|
||||
String label;
|
||||
|
||||
switch (type) {
|
||||
case SearchMatchType.title:
|
||||
color = AppTheme.info;
|
||||
label = 'Title';
|
||||
break;
|
||||
case SearchMatchType.message:
|
||||
color = AppTheme.success;
|
||||
label = 'Message';
|
||||
break;
|
||||
case SearchMatchType.tag:
|
||||
color = AppTheme.warning;
|
||||
label = 'Tag';
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.sm,
|
||||
vertical: Spacing.xxs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: AppTypography.labelSmall,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRoleBadge(String role) {
|
||||
Color color;
|
||||
String label;
|
||||
|
||||
switch (role) {
|
||||
case 'user':
|
||||
color = AppTheme.brandPrimary;
|
||||
label = 'You';
|
||||
break;
|
||||
case 'assistant':
|
||||
color = AppTheme.success;
|
||||
label = 'AI';
|
||||
break;
|
||||
case 'system':
|
||||
color = AppTheme.warning;
|
||||
label = 'System';
|
||||
break;
|
||||
default:
|
||||
color = AppTheme.neutral400;
|
||||
label = role;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.xs + Spacing.xxs,
|
||||
vertical: Spacing.xxs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: AppTypography.labelSmall,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHighlightedSnippet(
|
||||
ConduitThemeExtension theme,
|
||||
String highlightedText,
|
||||
) {
|
||||
// Simple implementation - in a real app you'd want proper HTML parsing
|
||||
final parts = highlightedText.split('<mark>');
|
||||
final spans = <InlineSpan>[];
|
||||
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
final part = parts[i];
|
||||
if (i == 0) {
|
||||
spans.add(TextSpan(text: part));
|
||||
} else {
|
||||
final markParts = part.split('</mark>');
|
||||
if (markParts.length >= 2) {
|
||||
// Highlighted part
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: markParts[0],
|
||||
style: TextStyle(
|
||||
backgroundColor: AppTheme.brandPrimary.withValues(alpha: 0.3),
|
||||
color: AppTheme.neutral50,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
// Rest of the text
|
||||
spans.add(TextSpan(text: markParts.sublist(1).join('</mark>')));
|
||||
} else {
|
||||
spans.add(TextSpan(text: part));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
style: theme.bodyMedium?.copyWith(
|
||||
color: AppTheme.neutral50.withValues(alpha: 0.8),
|
||||
height: 1.4,
|
||||
),
|
||||
children: spans,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime timestamp) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(timestamp);
|
||||
|
||||
if (diff.inDays > 7) {
|
||||
return '${timestamp.day}/${timestamp.month}/${timestamp.year}';
|
||||
} else if (diff.inDays > 0) {
|
||||
return '${diff.inDays}d ago';
|
||||
} else if (diff.inHours > 0) {
|
||||
return '${diff.inHours}h ago';
|
||||
} else if (diff.inMinutes > 0) {
|
||||
return '${diff.inMinutes}m ago';
|
||||
} else {
|
||||
return 'Just now';
|
||||
}
|
||||
}
|
||||
}
|
||||
688
lib/features/chat/widgets/documentation_message_widget.dart
Normal file
688
lib/features/chat/widgets/documentation_message_widget.dart
Normal file
@@ -0,0 +1,688 @@
|
||||
import 'package:flutter/material.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:async';
|
||||
import 'package:gpt_markdown/gpt_markdown.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import '../../../core/utils/reasoning_parser.dart';
|
||||
|
||||
class DocumentationMessageWidget extends ConsumerStatefulWidget {
|
||||
final dynamic message;
|
||||
final bool isUser;
|
||||
final bool isStreaming;
|
||||
final String? modelName;
|
||||
final VoidCallback? onCopy;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onRegenerate;
|
||||
final VoidCallback? onLike;
|
||||
final VoidCallback? onDislike;
|
||||
|
||||
const DocumentationMessageWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.isUser,
|
||||
this.isStreaming = false,
|
||||
this.modelName,
|
||||
this.onCopy,
|
||||
this.onEdit,
|
||||
this.onRegenerate,
|
||||
this.onLike,
|
||||
this.onDislike,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<DocumentationMessageWidget> createState() =>
|
||||
_DocumentationMessageWidgetState();
|
||||
}
|
||||
|
||||
class _DocumentationMessageWidgetState
|
||||
extends ConsumerState<DocumentationMessageWidget>
|
||||
with TickerProviderStateMixin {
|
||||
bool _showActions = false;
|
||||
bool _showReasoning = false;
|
||||
late AnimationController _fadeController;
|
||||
late AnimationController _slideController;
|
||||
ReasoningContent? _reasoningContent;
|
||||
String _renderedContent = '';
|
||||
Timer? _throttleTimer;
|
||||
String? _pendingContent;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_renderedContent = widget.message.content ?? '';
|
||||
_fadeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
_slideController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
// Parse reasoning content if present
|
||||
_updateReasoningContent();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DocumentationMessageWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Re-parse reasoning content when message content changes
|
||||
if (oldWidget.message.content != widget.message.content) {
|
||||
// Throttle markdown re-rendering for smoother streaming
|
||||
_scheduleRenderUpdate(widget.message.content ?? '');
|
||||
_updateReasoningContent();
|
||||
}
|
||||
}
|
||||
|
||||
void _updateReasoningContent() {
|
||||
if (!widget.isUser && widget.message.content != null) {
|
||||
final newReasoningContent = ReasoningParser.parseReasoningContent(
|
||||
widget.message.content!,
|
||||
);
|
||||
if (newReasoningContent != _reasoningContent) {
|
||||
setState(() {
|
||||
_reasoningContent = newReasoningContent;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleRenderUpdate(String rawContent) {
|
||||
final safe = _safeForStreaming(rawContent);
|
||||
if (_throttleTimer != null && _throttleTimer!.isActive) {
|
||||
_pendingContent = safe;
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _renderedContent = safe);
|
||||
} else {
|
||||
_renderedContent = safe;
|
||||
}
|
||||
_throttleTimer = Timer(const Duration(milliseconds: 80), () {
|
||||
if (!mounted) return;
|
||||
if (_pendingContent != null) {
|
||||
setState(() {
|
||||
_renderedContent = _pendingContent!;
|
||||
_pendingContent = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _safeForStreaming(String content) {
|
||||
if (content.isEmpty) return content;
|
||||
// Auto-close an unbalanced triple backtick fence during streaming so markdown stays valid
|
||||
final fenceCount = '```'.allMatches(content).length;
|
||||
if (fenceCount.isOdd) {
|
||||
return '$content\n```';
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fadeController.dispose();
|
||||
_slideController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleActions() {
|
||||
setState(() {
|
||||
_showActions = !_showActions;
|
||||
});
|
||||
|
||||
if (_showActions) {
|
||||
_fadeController.forward();
|
||||
_slideController.forward();
|
||||
} else {
|
||||
_fadeController.reverse();
|
||||
_slideController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.isUser) {
|
||||
return _buildUserMessage();
|
||||
} else {
|
||||
return _buildDocumentationMessage();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildUserMessage() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 16, left: 50, right: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: GestureDetector(
|
||||
onLongPress: () => _toggleActions(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.chatBubbleUser,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.chatBubbleUserBorder,
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.message.content,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.chatBubbleUserText,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
height: 1.5,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: const Duration(milliseconds: 400))
|
||||
.slideX(
|
||||
begin: 0.2,
|
||||
end: 0,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDocumentationMessage() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 24, left: 12, right: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Simplified AI Name and Avatar
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.buttonPrimary,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.small,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.auto_awesome,
|
||||
color: context.conduitTheme.buttonPrimaryText,
|
||||
size: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Text(
|
||||
widget.modelName ?? 'Assistant',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.bodySmall,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Reasoning Section (if present)
|
||||
if (_reasoningContent != null) ...[
|
||||
InkWell(
|
||||
onTap: () => setState(() => _showReasoning = !_showReasoning),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.sm,
|
||||
vertical: Spacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceContainer.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.dividerColor,
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_showReasoning
|
||||
? Icons.expand_less_rounded
|
||||
: Icons.expand_more_rounded,
|
||||
size: 16,
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Icon(
|
||||
Icons.psychology_outlined,
|
||||
size: 14,
|
||||
color: context.conduitTheme.buttonPrimary,
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Text(
|
||||
_reasoningContent!.summary.isNotEmpty
|
||||
? _reasoningContent!.summary
|
||||
: 'Thought for ${_reasoningContent!.formattedDuration}',
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.bodySmall,
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Expandable reasoning content
|
||||
AnimatedCrossFade(
|
||||
firstChild: const SizedBox.shrink(),
|
||||
secondChild: Container(
|
||||
margin: const EdgeInsets.only(top: Spacing.sm),
|
||||
padding: const EdgeInsets.all(Spacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceContainer.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.dividerColor,
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: SelectableText(
|
||||
_reasoningContent!.cleanedReasoning,
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.bodySmall,
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontFamily: 'monospace',
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
crossFadeState: _showReasoning
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.md),
|
||||
],
|
||||
|
||||
// Documentation-style content without heavy bubble; premium markdown
|
||||
GestureDetector(
|
||||
onLongPress: () => _toggleActions(),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.isStreaming &&
|
||||
(widget.message.content.trim().isEmpty ||
|
||||
widget.message.content == '[TYPING_INDICATOR]'))
|
||||
_buildTypingIndicator()
|
||||
else if (widget.isStreaming &&
|
||||
widget.message.content.isNotEmpty &&
|
||||
widget.message.content != '[TYPING_INDICATOR]')
|
||||
// While streaming, render markdown with throttling and safety fixes
|
||||
_buildEnhancedMarkdownContent(_renderedContent)
|
||||
else
|
||||
// After streaming finishes (or static content), render full markdown
|
||||
_buildEnhancedMarkdownContent(
|
||||
_reasoningContent?.mainContent ??
|
||||
widget.message.content,
|
||||
),
|
||||
|
||||
// Action buttons - inline and minimal
|
||||
if (_showActions) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
_buildActionButtons(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: const Duration(milliseconds: 300))
|
||||
.slideY(
|
||||
begin: 0.1,
|
||||
end: 0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEnhancedMarkdownContent(String content) {
|
||||
if (content.trim().isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final codeFence = RegExp(
|
||||
r"```([\w\-\+\.#]*)\n([\s\S]*?)```",
|
||||
multiLine: true,
|
||||
);
|
||||
final widgets = <Widget>[];
|
||||
int lastIndex = 0;
|
||||
for (final match in codeFence.allMatches(content)) {
|
||||
if (match.start > lastIndex) {
|
||||
final textSegment = content.substring(lastIndex, match.start);
|
||||
widgets.add(
|
||||
GptMarkdown(
|
||||
textSegment,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
height: 1.6,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final language = match.group(1)?.trim().isEmpty == true
|
||||
? null
|
||||
: match.group(1)!.trim();
|
||||
final code = match.group(2) ?? '';
|
||||
widgets.add(_buildCodeBlock(code, language));
|
||||
lastIndex = match.end;
|
||||
}
|
||||
|
||||
if (lastIndex < content.length) {
|
||||
final tail = content.substring(lastIndex);
|
||||
widgets.add(
|
||||
GptMarkdown(
|
||||
tail,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
height: 1.6,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: widgets
|
||||
.map(
|
||||
(w) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: Spacing.xs),
|
||||
child: w,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCodeBlock(String code, String? language) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.dividerColor.withValues(alpha: 0.7),
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.sm,
|
||||
vertical: Spacing.xs,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.chevron_left_slash_chevron_right
|
||||
: Icons.code,
|
||||
size: 14,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
language?.toUpperCase() ?? 'CODE',
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.labelSmall,
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => _copyToClipboard(code),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.xs,
|
||||
vertical: Spacing.xxs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.doc_on_clipboard
|
||||
: Icons.copy,
|
||||
size: 14,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Text(
|
||||
'Copy',
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.labelSmall,
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Spacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(AppBorderRadius.md),
|
||||
),
|
||||
),
|
||||
child: SelectableText(
|
||||
code.trimRight(),
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontFamily: AppTypography.monospaceFontFamily,
|
||||
fontSize: AppTypography.bodySmall,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _copyToClipboard(String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Code copied'),
|
||||
backgroundColor: context.conduitTheme.buttonPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Removed lightweight streaming text; we now stream markdown with throttling
|
||||
|
||||
Widget _buildTypingIndicator() {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
const statusText = 'Thinking about your question...';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
fontSize: AppTypography.bodyMedium,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Row(
|
||||
children: [
|
||||
_buildTypingDot(0),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
_buildTypingDot(200),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
_buildTypingDot(400),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypingDot(int delay) {
|
||||
return Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.textSecondary.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
),
|
||||
)
|
||||
.animate(onPlay: (controller) => controller.repeat())
|
||||
.scale(
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
begin: const Offset(1, 1),
|
||||
end: const Offset(1.3, 1.3),
|
||||
)
|
||||
.then(delay: Duration(milliseconds: delay))
|
||||
.scale(
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
begin: const Offset(1.3, 1.3),
|
||||
end: const Offset(1, 1),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.doc_on_clipboard
|
||||
: Icons.content_copy,
|
||||
label: 'Copy',
|
||||
onTap: widget.onCopy,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.hand_thumbsup
|
||||
: Icons.thumb_up_outlined,
|
||||
label: 'Like',
|
||||
onTap: widget.onLike,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.hand_thumbsdown
|
||||
: Icons.thumb_down_outlined,
|
||||
label: 'Dislike',
|
||||
onTap: widget.onDislike,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh,
|
||||
label: 'Regenerate',
|
||||
onTap: widget.onRegenerate,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.textPrimary.withValues(alpha: 0.04),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.textPrimary.withValues(alpha: 0.08),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: IconSize.sm,
|
||||
color: context.conduitTheme.textPrimary.withValues(alpha: 0.8),
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.labelMedium,
|
||||
color: context.conduitTheme.textPrimary.withValues(alpha: 0.8),
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
249
lib/features/chat/widgets/file_attachment_widget.dart
Normal file
249
lib/features/chat/widgets/file_attachment_widget.dart
Normal file
@@ -0,0 +1,249 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../services/file_attachment_service.dart';
|
||||
import '../../../shared/widgets/loading_states.dart';
|
||||
|
||||
class FileAttachmentWidget extends ConsumerWidget {
|
||||
const FileAttachmentWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final attachedFiles = ref.watch(attachedFilesProvider);
|
||||
|
||||
if (attachedFiles.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.sm, Spacing.md, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Attachments',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary.withValues(alpha: 0.7),
|
||||
fontSize: AppTypography.labelMedium,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: attachedFiles
|
||||
.map(
|
||||
(fileState) => Padding(
|
||||
padding: const EdgeInsets.only(right: Spacing.sm),
|
||||
child: _FileAttachmentCard(fileState: fileState),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).animate().fadeIn(duration: const Duration(milliseconds: 300));
|
||||
}
|
||||
}
|
||||
|
||||
class _FileAttachmentCard extends ConsumerWidget {
|
||||
final FileUploadState fileState;
|
||||
|
||||
const _FileAttachmentCard({required this.fileState});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Container(
|
||||
width: 160,
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.cardBackground,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
border: Border.all(
|
||||
color: _getBorderColor(fileState.status, context),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
fileState.fileIcon,
|
||||
style: const TextStyle(fontSize: AppTypography.headlineLarge),
|
||||
),
|
||||
const Spacer(),
|
||||
_buildStatusIcon(context),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
fileState.fileName,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.labelLarge,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Text(
|
||||
fileState.formattedSize,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary.withValues(alpha: 0.6),
|
||||
fontSize: AppTypography.labelMedium,
|
||||
),
|
||||
),
|
||||
if (fileState.status == FileUploadStatus.uploading) ...[
|
||||
const SizedBox(height: Spacing.sm),
|
||||
_buildProgressBar(context),
|
||||
],
|
||||
if (fileState.error != null) ...[
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Text(
|
||||
'Failed to upload',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.error,
|
||||
fontSize: AppTypography.labelMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon(BuildContext context) {
|
||||
switch (fileState.status) {
|
||||
case FileUploadStatus.pending:
|
||||
return Icon(
|
||||
Platform.isIOS ? CupertinoIcons.clock : Icons.schedule,
|
||||
size: IconSize.sm,
|
||||
color: context.conduitTheme.iconDisabled,
|
||||
);
|
||||
case FileUploadStatus.uploading:
|
||||
return ConduitLoading.inline(
|
||||
size: IconSize.sm,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
);
|
||||
case FileUploadStatus.completed:
|
||||
return Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.checkmark_circle_fill
|
||||
: Icons.check_circle,
|
||||
size: IconSize.sm,
|
||||
color: context.conduitTheme.success,
|
||||
);
|
||||
case FileUploadStatus.failed:
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
// Retry upload
|
||||
},
|
||||
child: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.exclamationmark_circle_fill
|
||||
: Icons.error,
|
||||
size: IconSize.sm,
|
||||
color: context.conduitTheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildProgressBar(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
child: LinearProgressIndicator(
|
||||
value: fileState.progress,
|
||||
backgroundColor: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: 0.1,
|
||||
),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
context.conduitTheme.buttonPrimary,
|
||||
),
|
||||
minHeight: 4,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getBorderColor(FileUploadStatus status, BuildContext context) {
|
||||
switch (status) {
|
||||
case FileUploadStatus.pending:
|
||||
return context.conduitTheme.textPrimary.withValues(alpha: 0.2);
|
||||
case FileUploadStatus.uploading:
|
||||
return context.conduitTheme.buttonPrimary.withValues(alpha: 0.5);
|
||||
case FileUploadStatus.completed:
|
||||
return context.conduitTheme.success.withValues(alpha: 0.3);
|
||||
case FileUploadStatus.failed:
|
||||
return context.conduitTheme.error.withValues(alpha: 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment preview for messages
|
||||
class MessageAttachmentPreview extends StatelessWidget {
|
||||
final List<String> fileIds;
|
||||
|
||||
const MessageAttachmentPreview({super.key, required this.fileIds});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (fileIds.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: Spacing.sm),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: fileIds
|
||||
.map(
|
||||
(fileId) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: 0.1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'📎',
|
||||
style: TextStyle(fontSize: AppTypography.bodyLarge),
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Text(
|
||||
'Attachment',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: 0.8,
|
||||
),
|
||||
fontSize: AppTypography.labelLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
242
lib/features/chat/widgets/file_viewer_dialog.dart
Normal file
242
lib/features/chat/widgets/file_viewer_dialog.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
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')}';
|
||||
}
|
||||
}
|
||||
708
lib/features/chat/widgets/folder_management_dialog.dart
Normal file
708
lib/features/chat/widgets/folder_management_dialog.dart
Normal file
@@ -0,0 +1,708 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../shared/theme/app_theme.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;
|
||||
|
||||
const FolderManagementDialog({super.key, this.conversation});
|
||||
|
||||
@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);
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
width: 400,
|
||||
constraints: const BoxConstraints(maxHeight: 600),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.cardBackground,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.cardBorder.withValues(alpha: 0.3),
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
context.conduitTheme.buttonPrimary,
|
||||
context.conduitTheme.buttonPrimary.withValues(alpha: 0.8),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(AppBorderRadius.xl),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.textInverse.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
),
|
||||
child: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.folder
|
||||
: Icons.folder_rounded,
|
||||
color: context.conduitTheme.textInverse,
|
||||
size: IconSize.md,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.conversation != null
|
||||
? 'Move to Folder'
|
||||
: 'Manage Folders',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textInverse,
|
||||
fontSize: AppTypography.headlineSmall,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
ConduitIconButton(
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.xmark
|
||||
: Icons.close_rounded,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Create new folder section
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.inputBackground,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.inputBorder,
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _nameController,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.inputText,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'New folder name',
|
||||
hintStyle: TextStyle(
|
||||
color: context.conduitTheme.inputPlaceholder
|
||||
.withValues(alpha: 0.5),
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.md,
|
||||
vertical: Spacing.sm,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.folder_badge_plus
|
||||
: Icons.create_new_folder_rounded,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
size: IconSize.md,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _createFolder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
ConduitButton(
|
||||
text: 'Create',
|
||||
onPressed: _isCreating ? null : _createFolder,
|
||||
isLoading: _isCreating,
|
||||
width: 80,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Divider
|
||||
Container(
|
||||
height: 0.5,
|
||||
margin: const EdgeInsets.symmetric(horizontal: Spacing.lg),
|
||||
color: context.conduitTheme.dividerColor.withValues(alpha: 0.3),
|
||||
),
|
||||
|
||||
// Folders list
|
||||
Expanded(
|
||||
child: folders.when(
|
||||
data: (folderList) => folderList.isEmpty
|
||||
? _buildEmptyState()
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: Spacing.sm,
|
||||
),
|
||||
itemCount: folderList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final folder = folderList[index];
|
||||
return _buildFolderTile(folder);
|
||||
},
|
||||
),
|
||||
loading: () => _buildLoadingState(),
|
||||
error: (error, _) => _buildErrorState(error),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom actions
|
||||
if (widget.conversation != null) ...[
|
||||
Container(
|
||||
height: 0.5,
|
||||
margin: const EdgeInsets.symmetric(horizontal: Spacing.lg),
|
||||
color: context.conduitTheme.dividerColor.withValues(alpha: 0.3),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ConduitButton(
|
||||
text: 'Remove from Folder',
|
||||
onPressed: () => _moveToFolder(null),
|
||||
isSecondary: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
Expanded(
|
||||
child: ConduitButton(
|
||||
text: 'Cancel',
|
||||
onPressed: () => Navigator.pop(context),
|
||||
isSecondary: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.xl),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.cardBackground.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
||||
),
|
||||
child: Icon(
|
||||
Platform.isIOS ? CupertinoIcons.folder : Icons.folder_outlined,
|
||||
size: 40,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.lg),
|
||||
Text(
|
||||
'No folders yet',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.headlineSmall,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
'Create a folder to organize\nyour conversations',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.labelLarge,
|
||||
height: 1.4,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
context.conduitTheme.buttonPrimary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: Spacing.lg),
|
||||
Text(
|
||||
'Loading folders...',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(Object error) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.xl),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.error.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.error_outline_rounded,
|
||||
size: 40,
|
||||
color: context.conduitTheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.lg),
|
||||
Text(
|
||||
'Failed to load folders',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.headlineSmall,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.labelLarge,
|
||||
height: 1.4,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFolderTile(Folder folder) {
|
||||
final isSelected = widget.conversation?.folderId == folder.id;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.lg,
|
||||
vertical: Spacing.xs,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.conversation != null
|
||||
? () => _moveToFolder(folder.id)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.1)
|
||||
: context.conduitTheme.cardBackground.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.3)
|
||||
: context.conduitTheme.cardBorder.withValues(alpha: 0.2),
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? context.conduitTheme.buttonPrimary.withValues(
|
||||
alpha: 0.2,
|
||||
)
|
||||
: context.conduitTheme.cardBorder.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
),
|
||||
child: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.folder_fill
|
||||
: Icons.folder_rounded,
|
||||
color: isSelected
|
||||
? context.conduitTheme.buttonPrimary
|
||||
: context.conduitTheme.iconSecondary,
|
||||
size: IconSize.md,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
folder.name,
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? context.conduitTheme.buttonPrimary
|
||||
: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.xxs),
|
||||
Text(
|
||||
'${folder.conversationIds.length} conversations',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.labelMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.conversation != null && isSelected)
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.buttonPrimary,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.round,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check_rounded,
|
||||
color: context.conduitTheme.textInverse,
|
||||
size: 16,
|
||||
),
|
||||
)
|
||||
else if (widget.conversation == null)
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_vert_rounded,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
size: IconSize.md,
|
||||
),
|
||||
color: context.conduitTheme.cardBackground,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
side: BorderSide(
|
||||
color: context.conduitTheme.cardBorder.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'rename':
|
||||
_renameFolder(folder);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteFolder(folder);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'rename',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.edit_rounded,
|
||||
size: 18,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
Text(
|
||||
'Rename',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_rounded,
|
||||
size: 18,
|
||||
color: context.conduitTheme.error,
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
Text(
|
||||
'Delete',
|
||||
style: TextStyle(
|
||||
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(context, 'Folder "$name" created');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
UiUtils.showMessage(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(
|
||||
context,
|
||||
folderId != null
|
||||
? 'Conversation moved to folder'
|
||||
: 'Conversation removed from folder',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
UiUtils.showMessage(context, 'Error moving conversation: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _renameFolder(Folder folder) async {
|
||||
final controller = TextEditingController(text: folder.name);
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppTheme.neutral700,
|
||||
title: Text(
|
||||
'Rename Folder',
|
||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
||||
),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
style: TextStyle(color: context.conduitTheme.inputText),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Folder name',
|
||||
hintStyle: TextStyle(
|
||||
color: context.conduitTheme.inputPlaceholder.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: context.conduitTheme.inputBorder.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: context.conduitTheme.inputBorder.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: context.conduitTheme.buttonPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: context.conduitTheme.buttonPrimary,
|
||||
),
|
||||
child: const Text('Rename'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
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(context, 'Folder renamed to "$result"');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
UiUtils.showMessage(context, 'Failed to rename folder: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
void _deleteFolder(Folder folder) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: context.conduitTheme.cardBackground,
|
||||
title: Text(
|
||||
'Delete Folder',
|
||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
||||
),
|
||||
content: Text(
|
||||
'Are you sure you want to delete "${folder.name}"?\n\nThis action cannot be undone. Conversations in this folder will be moved to the main folder.',
|
||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: context.conduitTheme.error,
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
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(context, 'Folder "${folder.name}" deleted');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
UiUtils.showMessage(context, 'Failed to delete folder: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1056
lib/features/chat/widgets/message_batch_widget.dart
Normal file
1056
lib/features/chat/widgets/message_batch_widget.dart
Normal file
File diff suppressed because it is too large
Load Diff
792
lib/features/chat/widgets/modern_chat_input.dart
Normal file
792
lib/features/chat/widgets/modern_chat_input.dart
Normal file
@@ -0,0 +1,792 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:async';
|
||||
import '../providers/chat_providers.dart';
|
||||
|
||||
import '../../../shared/utils/platform_utils.dart';
|
||||
|
||||
class ModernChatInput extends ConsumerStatefulWidget {
|
||||
final Function(String) onSendMessage;
|
||||
final bool enabled;
|
||||
final Function()? onVoiceInput;
|
||||
final Function()? onFileAttachment;
|
||||
final Function()? onImageAttachment;
|
||||
final Function()? onCameraCapture;
|
||||
|
||||
const ModernChatInput({
|
||||
super.key,
|
||||
required this.onSendMessage,
|
||||
this.enabled = true,
|
||||
this.onVoiceInput,
|
||||
this.onFileAttachment,
|
||||
this.onImageAttachment,
|
||||
this.onCameraCapture,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ModernChatInput> createState() => _ModernChatInputState();
|
||||
}
|
||||
|
||||
class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
with TickerProviderStateMixin {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
final bool _isRecording = false;
|
||||
bool _isExpanded = true; // Start expanded for better UX
|
||||
// TODO: Implement voice input functionality
|
||||
// final String _voiceInputText = '';
|
||||
bool _hasText = false; // track locally without rebuilding on each keystroke
|
||||
StreamSubscription<String>? _voiceStreamSubscription;
|
||||
late AnimationController _expandController;
|
||||
late AnimationController _pulseController;
|
||||
Timer? _blurCollapseTimer;
|
||||
bool _hasAutoFocusedOnce = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_expandController = AnimationController(
|
||||
duration:
|
||||
AnimationDuration.fast, // Faster animation for better responsiveness
|
||||
vsync: this,
|
||||
value: 1.0, // Start expanded
|
||||
);
|
||||
_pulseController = AnimationController(
|
||||
duration: AnimationDuration.slow,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
// Listen for text changes and update only when emptiness flips
|
||||
_controller.addListener(() {
|
||||
final has = _controller.text.trim().isNotEmpty;
|
||||
if (has != _hasText) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _hasText = has);
|
||||
// Intelligent expansion: expand when user starts typing
|
||||
if (has && !_isExpanded) {
|
||||
_setExpanded(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Intelligent expand/collapse around focus changes
|
||||
_focusNode.addListener(() {
|
||||
// Cancel any pending blur-driven collapse
|
||||
_blurCollapseTimer?.cancel();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final hasFocus = _focusNode.hasFocus;
|
||||
if (hasFocus) {
|
||||
if (!_isExpanded) _setExpanded(true);
|
||||
} else {
|
||||
// Defer collapse slightly to avoid IME show/hide race conditions
|
||||
_blurCollapseTimer = Timer(const Duration(milliseconds: 160), () {
|
||||
if (!mounted) return;
|
||||
if (_focusNode.hasFocus) return; // focus came back
|
||||
// Collapse only when keyboard is fully hidden to avoid flicker
|
||||
final keyboardVisible =
|
||||
MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
if (keyboardVisible) return;
|
||||
final has = _controller.text.trim().isNotEmpty;
|
||||
if (!has && _isExpanded) {
|
||||
_setExpanded(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Let autofocus handle the focus - no manual intervention
|
||||
// The TextField's autofocus: true should handle focus and keyboard automatically
|
||||
// Additionally, request focus after first frame to ensure reliability across platforms
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!_hasAutoFocusedOnce && widget.enabled) {
|
||||
_ensureFocusedIfEnabled();
|
||||
_hasAutoFocusedOnce = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
_expandController.dispose();
|
||||
_pulseController.dispose();
|
||||
_blurCollapseTimer?.cancel();
|
||||
_voiceStreamSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _ensureFocusedIfEnabled() {
|
||||
if (!widget.enabled) return;
|
||||
if (!_focusNode.hasFocus) {
|
||||
FocusScope.of(context).requestFocus(_focusNode);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ModernChatInput oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.enabled && !oldWidget.enabled && !_hasAutoFocusedOnce) {
|
||||
// Became enabled (e.g., after selecting a model) → focus the input
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_ensureFocusedIfEnabled();
|
||||
_hasAutoFocusedOnce = true;
|
||||
});
|
||||
}
|
||||
if (!widget.enabled && oldWidget.enabled) {
|
||||
// Became disabled → collapse and hide keyboard
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (_isExpanded) _setExpanded(false);
|
||||
if (_focusNode.hasFocus) {
|
||||
_focusNode.unfocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty || !widget.enabled) return;
|
||||
|
||||
PlatformUtils.lightHaptic();
|
||||
widget.onSendMessage(text);
|
||||
_controller.clear();
|
||||
// Keep input expanded and focused for better UX - don't dismiss keyboard
|
||||
// KeyboardUtils.dismissKeyboard(context);
|
||||
// _setExpanded(false);
|
||||
}
|
||||
|
||||
void _setExpanded(bool expanded) {
|
||||
if (_isExpanded == expanded) return;
|
||||
setState(() {
|
||||
_isExpanded = expanded;
|
||||
});
|
||||
if (expanded) {
|
||||
_expandController.forward();
|
||||
} else {
|
||||
_expandController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Check if assistant is currently generating by checking last assistant message streaming
|
||||
final messages = ref.watch(chatMessagesProvider);
|
||||
final isGenerating =
|
||||
messages.isNotEmpty &&
|
||||
messages.last.role == 'assistant' &&
|
||||
messages.last.isStreaming;
|
||||
final stopGeneration = ref.read(stopGenerationProvider);
|
||||
|
||||
return Container(
|
||||
// Transparent wrapper so rounded corners are visible against page background
|
||||
color: Colors.transparent,
|
||||
padding: EdgeInsets.only(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: Spacing.xs.toDouble(),
|
||||
bottom: 0,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Web search status indicator
|
||||
_buildWebSearchStatusIndicator(),
|
||||
|
||||
// Main input area with unified 2-row design
|
||||
Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.inputBackground,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(AppBorderRadius.xl),
|
||||
bottom: Radius.circular(0),
|
||||
),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: context.conduitTheme.inputBorder,
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
left: BorderSide(
|
||||
color: context.conduitTheme.inputBorder,
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
right: BorderSide(
|
||||
color: context.conduitTheme.inputBorder,
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
// Removed bottom border to eliminate divider
|
||||
),
|
||||
boxShadow: ConduitShadows.input,
|
||||
),
|
||||
width: double.infinity,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
// cap the input area to 40% of screen height to avoid bottom overflow
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.4,
|
||||
),
|
||||
child: AnimatedSize(
|
||||
duration:
|
||||
AnimationDuration.fast, // Faster for better responsiveness
|
||||
curve: Curves.fastOutSlowIn, // More efficient curve
|
||||
alignment: Alignment.topCenter,
|
||||
child: SingleChildScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Collapsed/Expanded top row: text input with left/right buttons in collapsed
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(Spacing.inputPadding),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (!_isExpanded) ...[
|
||||
_buildRoundButton(
|
||||
icon: Icons.add,
|
||||
onTap: widget.enabled
|
||||
? _showAttachmentOptions
|
||||
: null,
|
||||
tooltip: 'Add attachment',
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
],
|
||||
// Text input expands to fill
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
textField: true,
|
||||
label: 'Message input',
|
||||
hint: 'Type your message',
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
enabled: widget.enabled,
|
||||
autofocus: false,
|
||||
maxLines: _isExpanded ? null : 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction: TextInputAction.newline,
|
||||
showCursor: true,
|
||||
cursorColor: context.conduitTheme.inputText,
|
||||
style: AppTypography.chatMessageStyle
|
||||
.copyWith(
|
||||
color: context.conduitTheme.inputText,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message...',
|
||||
hintStyle: TextStyle(
|
||||
color:
|
||||
context.conduitTheme.inputPlaceholder,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
fontWeight: _isRecording
|
||||
? FontWeight.w500
|
||||
: FontWeight.w400,
|
||||
fontStyle: _isRecording
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
),
|
||||
// Ensure the text field background matches its parent container
|
||||
// and does not use the global InputDecorationTheme fill
|
||||
filled: false,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
isDense: true,
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
// Removed onChanged setState to reduce rebuilds
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
onTap: () {
|
||||
if (!widget.enabled) return;
|
||||
if (!_isExpanded) {
|
||||
_setExpanded(true);
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_ensureFocusedIfEnabled();
|
||||
});
|
||||
} else {
|
||||
_ensureFocusedIfEnabled();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isExpanded) ...[
|
||||
const SizedBox(width: Spacing.sm),
|
||||
// Primary action button (Send/Stop) when collapsed
|
||||
_buildPrimaryButton(
|
||||
_hasText,
|
||||
isGenerating,
|
||||
stopGeneration,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Expanded bottom row with additional options
|
||||
if (_isExpanded) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Spacing.inputPadding,
|
||||
right: Spacing.inputPadding,
|
||||
bottom: Spacing.inputPadding,
|
||||
),
|
||||
child: FadeTransition(
|
||||
opacity: _expandController,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildRoundButton(
|
||||
icon: Icons.add,
|
||||
onTap: widget.enabled
|
||||
? _showAttachmentOptions
|
||||
: null,
|
||||
tooltip: 'Add attachment',
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
Flexible(
|
||||
child: Center(child: _buildResearchToggle()),
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
// Microphone button: call provided callback for premium voice UI
|
||||
_buildRoundButton(
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.mic_fill
|
||||
: Icons.mic,
|
||||
onTap: widget.enabled
|
||||
? widget.onVoiceInput
|
||||
: null,
|
||||
tooltip: 'Voice input',
|
||||
isActive: _isRecording,
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
// Primary action button (Send/Stop) when expanded
|
||||
_buildPrimaryButton(
|
||||
_hasText,
|
||||
isGenerating,
|
||||
stopGeneration,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrimaryButton(
|
||||
bool hasText,
|
||||
bool isGenerating,
|
||||
void Function() stopGeneration,
|
||||
) {
|
||||
// Spec: 48px touch target, circular radius, md icon size
|
||||
const double buttonSize = TouchTarget.comfortable; // 48.0
|
||||
const double radius = AppBorderRadius.round; // big to ensure circle
|
||||
|
||||
final enabled = !isGenerating && hasText && widget.enabled;
|
||||
|
||||
// Generating -> STOP variant
|
||||
if (isGenerating) {
|
||||
return Tooltip(
|
||||
message: 'Stop generating',
|
||||
child: GestureDetector(
|
||||
onTap: stopGeneration,
|
||||
child: Container(
|
||||
width: buttonSize,
|
||||
height: buttonSize,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.error.withValues(
|
||||
alpha: Alpha.buttonPressed,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.error,
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
boxShadow: ConduitShadows.button,
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: buttonSize - 18,
|
||||
height: buttonSize - 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: BorderWidth.medium,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
context.conduitTheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Platform.isIOS ? CupertinoIcons.stop_fill : Icons.stop,
|
||||
size: IconSize.medium,
|
||||
color: context.conduitTheme.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Default SEND variant
|
||||
return Tooltip(
|
||||
message: enabled ? 'Send message' : 'Send',
|
||||
child: GestureDetector(
|
||||
onTap: enabled ? _sendMessage : null,
|
||||
child: Opacity(
|
||||
opacity: enabled ? Alpha.primary : Alpha.disabled,
|
||||
child: IgnorePointer(
|
||||
ignoring: !enabled,
|
||||
child: Container(
|
||||
width: buttonSize,
|
||||
height: buttonSize,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.cardBackground,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
border: Border.all(
|
||||
color: enabled
|
||||
? context.conduitTheme.cardBorder
|
||||
: context.conduitTheme.cardBorder.withValues(
|
||||
alpha: Alpha.medium,
|
||||
),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
boxShadow: ConduitShadows.button,
|
||||
),
|
||||
child: Icon(
|
||||
Platform.isIOS ? CupertinoIcons.arrow_up : Icons.arrow_upward,
|
||||
size: IconSize.medium,
|
||||
color: enabled
|
||||
? context.conduitTheme.textPrimary
|
||||
: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.disabled,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRoundButton({
|
||||
required IconData icon,
|
||||
VoidCallback? onTap,
|
||||
String? tooltip,
|
||||
bool isActive = false,
|
||||
bool showBackground = true,
|
||||
}) {
|
||||
return Tooltip(
|
||||
message: tooltip ?? '',
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: TouchTarget.comfortable,
|
||||
height: TouchTarget.comfortable,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.buttonHover,
|
||||
)
|
||||
: showBackground
|
||||
? context.conduitTheme.cardBackground
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
|
||||
border: Border.all(
|
||||
color: isActive
|
||||
? context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.buttonHover + Alpha.subtle,
|
||||
)
|
||||
: showBackground
|
||||
? context.conduitTheme.cardBorder
|
||||
: Colors.transparent,
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
boxShadow: (isActive || showBackground)
|
||||
? ConduitShadows.button
|
||||
: null,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: IconSize.medium,
|
||||
color: widget.enabled
|
||||
? (isActive
|
||||
? context.conduitTheme.textPrimary
|
||||
: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.strong,
|
||||
))
|
||||
: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.disabled,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResearchToggle() {
|
||||
final webSearchEnabled = ref.watch(
|
||||
webSearchEnabledProvider.select((enabled) => enabled),
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: widget.enabled
|
||||
? () {
|
||||
ref.read(webSearchEnabledProvider.notifier).state =
|
||||
!webSearchEnabled;
|
||||
}
|
||||
: null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.md,
|
||||
vertical: Spacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: webSearchEnabled
|
||||
? context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.buttonHover,
|
||||
)
|
||||
: context.conduitTheme.surfaceBackground.withValues(
|
||||
alpha: Alpha.subtle,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
|
||||
border: Border.all(
|
||||
color: webSearchEnabled
|
||||
? context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.buttonHover + Alpha.subtle,
|
||||
)
|
||||
: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.subtle,
|
||||
),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Platform.isIOS ? CupertinoIcons.search : Icons.travel_explore,
|
||||
size: IconSize.small,
|
||||
color: widget.enabled
|
||||
? (webSearchEnabled
|
||||
? context.conduitTheme.textPrimary
|
||||
: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.strong,
|
||||
))
|
||||
: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.disabled,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Search',
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.bodySmall,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: widget.enabled
|
||||
? (webSearchEnabled
|
||||
? context.conduitTheme.textPrimary
|
||||
: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.strong,
|
||||
))
|
||||
: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.disabled,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWebSearchStatusIndicator() {
|
||||
final webSearchEnabled = ref.watch(
|
||||
webSearchEnabledProvider.select((enabled) => enabled),
|
||||
);
|
||||
|
||||
if (!webSearchEnabled) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.md,
|
||||
vertical: Spacing.xs,
|
||||
),
|
||||
margin: const EdgeInsets.only(
|
||||
left: Spacing.md,
|
||||
right: Spacing.md,
|
||||
bottom: Spacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.info.withValues(
|
||||
alpha: Alpha.badgeBackground,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.badge),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.info.withValues(alpha: Alpha.subtle),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Platform.isIOS ? CupertinoIcons.search : Icons.travel_explore,
|
||||
size: IconSize.small,
|
||||
color: context.conduitTheme.info,
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Text(
|
||||
'Web search on',
|
||||
style: AppTypography.captionStyle.copyWith(
|
||||
color: context.conduitTheme.info,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAttachmentOptions() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(AppBorderRadius.bottomSheet),
|
||||
),
|
||||
boxShadow: ConduitShadows.modal,
|
||||
),
|
||||
padding: const EdgeInsets.all(Spacing.bottomSheetPadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Handle bar
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.medium,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.lg),
|
||||
|
||||
// Options grid
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildAttachmentOption(
|
||||
icon: Platform.isIOS ? CupertinoIcons.doc : Icons.attach_file,
|
||||
label: 'File',
|
||||
onTap: () {
|
||||
Navigator.pop(context); // Close modal
|
||||
widget.onFileAttachment?.call();
|
||||
},
|
||||
),
|
||||
_buildAttachmentOption(
|
||||
icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image,
|
||||
label: 'Photo',
|
||||
onTap: () {
|
||||
Navigator.pop(context); // Close modal
|
||||
widget.onImageAttachment?.call();
|
||||
},
|
||||
),
|
||||
_buildAttachmentOption(
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.camera
|
||||
: Icons.camera_alt,
|
||||
label: 'Camera',
|
||||
onTap: () {
|
||||
Navigator.pop(context); // Close modal
|
||||
widget.onCameraCapture?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Spacing.lg),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAttachmentOption({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.subtle,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.subtle,
|
||||
),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: context.conduitTheme.textPrimary,
|
||||
size: IconSize.xl,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.labelStyle.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
811
lib/features/chat/widgets/modern_message_bubble.dart
Normal file
811
lib/features/chat/widgets/modern_message_bubble.dart
Normal file
@@ -0,0 +1,811 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
|
||||
class ModernMessageBubble extends ConsumerStatefulWidget {
|
||||
final dynamic message;
|
||||
final bool isUser;
|
||||
final bool isStreaming;
|
||||
final String? modelName;
|
||||
final VoidCallback? onCopy;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onRegenerate;
|
||||
final VoidCallback? onLike;
|
||||
final VoidCallback? onDislike;
|
||||
|
||||
const ModernMessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.isUser,
|
||||
this.isStreaming = false,
|
||||
this.modelName,
|
||||
this.onCopy,
|
||||
this.onEdit,
|
||||
this.onRegenerate,
|
||||
this.onLike,
|
||||
this.onDislike,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ModernMessageBubble> createState() =>
|
||||
_ModernMessageBubbleState();
|
||||
}
|
||||
|
||||
class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
|
||||
with TickerProviderStateMixin {
|
||||
bool _showActions = false;
|
||||
late AnimationController _fadeController;
|
||||
late AnimationController _slideController;
|
||||
static const int _maxCachedImages = 24;
|
||||
|
||||
// Cache for image base64 data to prevent repeated API calls
|
||||
final Map<String, String?> _imageCache = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fadeController = AnimationController(
|
||||
duration: AnimationDuration.microInteraction,
|
||||
vsync: this,
|
||||
);
|
||||
_slideController = AnimationController(
|
||||
duration: AnimationDuration.messageSlide,
|
||||
vsync: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fadeController.dispose();
|
||||
_slideController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleActions() {
|
||||
setState(() {
|
||||
_showActions = !_showActions;
|
||||
});
|
||||
|
||||
if (_showActions) {
|
||||
_fadeController.forward();
|
||||
_slideController.forward();
|
||||
} else {
|
||||
_fadeController.reverse();
|
||||
_slideController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.isUser) {
|
||||
return _buildUserMessage();
|
||||
} else {
|
||||
return _buildAssistantMessage();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildUserMessage() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: Spacing.messagePadding,
|
||||
left: Spacing.xxxl,
|
||||
right: Spacing.xs,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: GestureDetector(
|
||||
onLongPress: () => _toggleActions(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.messagePadding,
|
||||
vertical: Spacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
context.conduitTheme.chatBubbleUser.withValues(
|
||||
alpha: 0.95,
|
||||
),
|
||||
context.conduitTheme.chatBubbleUser,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.messageBubble,
|
||||
),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.chatBubbleUserBorder,
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
boxShadow: ConduitShadows.high,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Display images if any
|
||||
if (widget.message.attachmentIds != null &&
|
||||
widget.message.attachmentIds!.isNotEmpty)
|
||||
_buildAttachmentImages(),
|
||||
|
||||
// Display text content if any
|
||||
if (widget.message.content.isNotEmpty) ...[
|
||||
if (widget.message.attachmentIds != null &&
|
||||
widget.message.attachmentIds!.isNotEmpty)
|
||||
const SizedBox(height: Spacing.sm),
|
||||
_buildCustomText(
|
||||
widget.message.content,
|
||||
context.conduitTheme.chatBubbleUserText,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: AnimationDuration.messageAppear)
|
||||
.slideX(
|
||||
begin: AnimationValues.messageSlideDistance,
|
||||
end: 0,
|
||||
duration: AnimationDuration.messageSlide,
|
||||
curve: AnimationCurves.messageSlide,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssistantMessage() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: Spacing.lg,
|
||||
left: Spacing.xs,
|
||||
right: Spacing.xxxl,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Simplified AI Name and Avatar
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
context.conduitTheme.buttonPrimary.withValues(
|
||||
alpha: 0.9,
|
||||
),
|
||||
context.conduitTheme.buttonPrimary,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.small,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.auto_awesome,
|
||||
color: context.conduitTheme.buttonPrimaryText,
|
||||
size: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Text(
|
||||
widget.modelName ?? 'Assistant',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.bodySmall,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Message Content
|
||||
GestureDetector(
|
||||
onLongPress: () => _toggleActions(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(Spacing.messagePadding),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.chatBubbleAssistant,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.messageBubble,
|
||||
),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.chatBubbleAssistantBorder,
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
boxShadow: ConduitShadows.low,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Check for typing indicator - show for empty content OR explicit typing indicator during streaming
|
||||
if ((widget.message.content.isEmpty ||
|
||||
widget.message.content == '[TYPING_INDICATOR]') &&
|
||||
widget.isStreaming) ...[
|
||||
_buildTypingIndicator(),
|
||||
] else if (widget.message.content.isNotEmpty &&
|
||||
widget.message.content != '[TYPING_INDICATOR]') ...[
|
||||
_buildCustomText(
|
||||
widget.message.content,
|
||||
context.conduitTheme.chatBubbleAssistantText,
|
||||
),
|
||||
] else
|
||||
// Fallback: show empty state for non-streaming empty messages
|
||||
const SizedBox.shrink(),
|
||||
|
||||
// Action buttons
|
||||
if (_showActions) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
_buildActionButtons(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: AnimationDuration.messageAppear)
|
||||
.slideX(
|
||||
begin: -AnimationValues.messageSlideDistance,
|
||||
end: 0,
|
||||
duration: AnimationDuration.messageSlide,
|
||||
curve: AnimationCurves.messageSlide,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAttachmentImages() {
|
||||
if (widget.message.attachmentIds == null ||
|
||||
widget.message.attachmentIds!.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: widget.message.attachmentIds!.map<Widget>((attachmentId) {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return const SizedBox.shrink();
|
||||
|
||||
return FutureBuilder<String?>(
|
||||
future: _getCachedImageBase64(api, attachmentId),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Container(
|
||||
height: 150,
|
||||
width: 200,
|
||||
margin: const EdgeInsets.only(bottom: Spacing.xs),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: context.conduitTheme.buttonPrimary,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.hasError ||
|
||||
!snapshot.hasData ||
|
||||
snapshot.data == null) {
|
||||
return Container(
|
||||
height: 100,
|
||||
width: 150,
|
||||
margin: const EdgeInsets.only(bottom: Spacing.xs),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.textSecondary.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.broken_image_outlined,
|
||||
color: context.conduitTheme.textSecondary,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Text(
|
||||
'Image unavailable',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final base64Data = snapshot.data!;
|
||||
try {
|
||||
// Handle data URLs (data:image/...;base64,...)
|
||||
String actualBase64;
|
||||
if (base64Data.startsWith('data:')) {
|
||||
// Extract base64 part from data URL
|
||||
final commaIndex = base64Data.indexOf(',');
|
||||
if (commaIndex != -1) {
|
||||
actualBase64 = base64Data.substring(commaIndex + 1);
|
||||
} else {
|
||||
throw Exception('Invalid data URL format');
|
||||
}
|
||||
} else {
|
||||
// Direct base64 string
|
||||
actualBase64 = base64Data;
|
||||
}
|
||||
|
||||
final imageBytes = base64.decode(actualBase64);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: Spacing.xs),
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 300,
|
||||
maxHeight: 300,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
child: Image.memory(
|
||||
imageBytes,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
height: 100,
|
||||
width: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground
|
||||
.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.sm,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: context.conduitTheme.error,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Text(
|
||||
'Failed to load image',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.error,
|
||||
fontSize: AppTypography.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
return Container(
|
||||
height: 100,
|
||||
width: 150,
|
||||
margin: const EdgeInsets.only(bottom: Spacing.xs),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: context.conduitTheme.error,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Text(
|
||||
'Invalid image format',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.error,
|
||||
fontSize: AppTypography.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _getCachedImageBase64(dynamic api, String fileId) async {
|
||||
// Check cache first to prevent repeated API calls
|
||||
if (_imageCache.containsKey(fileId)) {
|
||||
return _imageCache[fileId];
|
||||
}
|
||||
|
||||
// If not in cache, get the image and cache it
|
||||
final result = await _getImageBase64(api, fileId);
|
||||
// Simple LRU-like eviction to bound memory
|
||||
if (_imageCache.length >= _maxCachedImages) {
|
||||
_imageCache.remove(_imageCache.keys.first);
|
||||
}
|
||||
_imageCache[fileId] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<String?> _getImageBase64(dynamic api, String fileId) async {
|
||||
try {
|
||||
// Check if this is already a data URL (for images)
|
||||
if (fileId.startsWith('data:')) {
|
||||
return fileId;
|
||||
}
|
||||
|
||||
// First, get file info to determine if it's an image
|
||||
final fileInfo = await api.getFileInfo(fileId);
|
||||
final fileName =
|
||||
fileInfo['filename'] ??
|
||||
fileInfo['meta']?['name'] ??
|
||||
fileInfo['name'] ??
|
||||
fileInfo['file_name'] ??
|
||||
fileInfo['original_name'] ??
|
||||
fileInfo['original_filename'] ??
|
||||
'';
|
||||
final ext = fileName.toLowerCase().split('.').last;
|
||||
|
||||
// Only process image files
|
||||
if (!['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) {
|
||||
debugPrint('DEBUG: Skipping non-image file: $fileName');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get file content as base64 string
|
||||
final fileContent = await api.getFileContent(fileId);
|
||||
return fileContent;
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error getting image content for $fileId: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCustomText(String text, [Color? textColor]) {
|
||||
// Simple markdown-like parsing for efficiency
|
||||
final lines = text.split('\n');
|
||||
final widgets = <Widget>[];
|
||||
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
final line = lines[i];
|
||||
if (line.trim().isEmpty) {
|
||||
if (i < lines.length - 1) {
|
||||
widgets.add(const SizedBox(height: Spacing.sm));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse basic markdown
|
||||
Widget textWidget = _parseMarkdownLine(line, textColor);
|
||||
|
||||
if (i < lines.length - 1) {
|
||||
widgets.add(textWidget);
|
||||
widgets.add(const SizedBox(height: Spacing.xs));
|
||||
} else {
|
||||
widgets.add(textWidget);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: widgets,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _parseMarkdownLine(String line, [Color? textColor]) {
|
||||
// Handle code blocks
|
||||
if (line.startsWith('```')) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: Spacing.xs),
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground.withValues(
|
||||
alpha: Alpha.badgeBackground,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.subtle,
|
||||
),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
line.substring(3),
|
||||
style: AppTypography.chatCodeStyle.copyWith(
|
||||
color: textColor ?? context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: AnimationDuration.microInteraction)
|
||||
.slideX(
|
||||
begin: 0.1,
|
||||
end: 0,
|
||||
duration: AnimationDuration.microInteraction,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle headers
|
||||
if (line.startsWith('#')) {
|
||||
int level = 0;
|
||||
while (level < line.length && line[level] == '#') {
|
||||
level++;
|
||||
}
|
||||
final fontSize = AppTypography.headlineMedium - (level * 2);
|
||||
return Text(
|
||||
line.substring(level).trim(),
|
||||
style: AppTypography.headlineMediumStyle.copyWith(
|
||||
color: textColor ?? context.conduitTheme.textPrimary,
|
||||
fontSize: fontSize.toDouble(),
|
||||
),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: AnimationDuration.microInteraction)
|
||||
.slideX(
|
||||
begin: 0.1,
|
||||
end: 0,
|
||||
duration: AnimationDuration.microInteraction,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle inline code
|
||||
if (line.contains('`')) {
|
||||
final parts = line.split('`');
|
||||
final widgets = <Widget>[];
|
||||
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
if (parts[i].isNotEmpty) {
|
||||
if (i % 2 == 1) {
|
||||
// Inline code
|
||||
widgets.add(
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.xs + Spacing.xxs,
|
||||
vertical: Spacing.xxs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.badgeBackground,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
),
|
||||
child: Text(
|
||||
parts[i],
|
||||
style: AppTypography.chatCodeStyle.copyWith(
|
||||
color: textColor ?? context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Regular text
|
||||
widgets.add(
|
||||
Text(
|
||||
parts[i],
|
||||
style: AppTypography.chatMessageStyle.copyWith(
|
||||
color: textColor ?? context.conduitTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.start,
|
||||
children: widgets,
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: AnimationDuration.microInteraction)
|
||||
.slideX(
|
||||
begin: 0.1,
|
||||
end: 0,
|
||||
duration: AnimationDuration.microInteraction,
|
||||
);
|
||||
}
|
||||
|
||||
// Regular text
|
||||
return Text(
|
||||
line,
|
||||
style: AppTypography.chatMessageStyle.copyWith(
|
||||
color: textColor ?? context.conduitTheme.textPrimary,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: AnimationDuration.microInteraction)
|
||||
.slideX(
|
||||
begin: 0.1,
|
||||
end: 0,
|
||||
duration: AnimationDuration.microInteraction,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypingIndicator() {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
// Show only animated dots, no text
|
||||
return _buildTypingDots();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypingDots() {
|
||||
return Row(
|
||||
children: List.generate(3, (index) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(right: index < 2 ? Spacing.xs : 0),
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.loadingIndicator,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
)
|
||||
.animate(onPlay: (controller) => controller.repeat())
|
||||
.scale(
|
||||
duration: AnimationDuration.typingIndicator,
|
||||
begin: const Offset(
|
||||
AnimationValues.typingIndicatorScale,
|
||||
AnimationValues.typingIndicatorScale,
|
||||
),
|
||||
end: const Offset(1.0, 1.0),
|
||||
curve: AnimationCurves.typingIndicator,
|
||||
delay: Duration(
|
||||
milliseconds: index * 200,
|
||||
), // Stagger the animation
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Wrap(
|
||||
spacing: Spacing.sm,
|
||||
runSpacing: Spacing.sm,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined,
|
||||
label: 'Edit',
|
||||
onTap: widget.onEdit,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.doc_on_clipboard
|
||||
: Icons.content_copy,
|
||||
label: 'Copy',
|
||||
onTap: widget.onCopy,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.speaker_1
|
||||
: Icons.volume_up_outlined,
|
||||
label: 'Read',
|
||||
onTap: () => _handleTextToSpeech(context),
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.hand_thumbsup
|
||||
: Icons.thumb_up_outlined,
|
||||
label: 'Like',
|
||||
onTap: widget.onLike,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.hand_thumbsdown
|
||||
: Icons.thumb_down_outlined,
|
||||
label: 'Dislike',
|
||||
onTap: widget.onDislike,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh,
|
||||
label: 'Regenerate',
|
||||
onTap: widget.onRegenerate,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.actionButtonPadding,
|
||||
vertical: Spacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground.withValues(
|
||||
alpha: Alpha.buttonHover,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.actionButton),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.subtle,
|
||||
),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: IconSize.small,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.labelStyle.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).animate().scale(
|
||||
duration: AnimationDuration.buttonPress,
|
||||
curve: AnimationCurves.buttonPress,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTextToSpeech(BuildContext context) {
|
||||
// Implementation for text-to-speech functionality
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Text-to-speech feature coming soon!'),
|
||||
backgroundColor: context.conduitTheme.info,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.snackbar),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
259
lib/features/chat/widgets/tag_management_dialog.dart
Normal file
259
lib/features/chat/widgets/tag_management_dialog.dart
Normal file
@@ -0,0 +1,259 @@
|
||||
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) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Tag "$tag" added')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error adding tag: $e'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} 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) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Tag "$tag" removed')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error removing tag: $e'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
439
lib/features/files/views/files_page.dart
Normal file
439
lib/features/files/views/files_page.dart
Normal file
@@ -0,0 +1,439 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import '../../../core/widgets/error_boundary.dart';
|
||||
import '../../../core/services/navigation_service.dart';
|
||||
import '../../../shared/widgets/improved_loading_states.dart';
|
||||
|
||||
import '../../../shared/utils/ui_utils.dart';
|
||||
|
||||
/// Files page for managing documents and uploads
|
||||
class FilesPage extends ConsumerStatefulWidget {
|
||||
const FilesPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<FilesPage> createState() => _FilesPageState();
|
||||
}
|
||||
|
||||
class _FilesPageState extends ConsumerState<FilesPage>
|
||||
with TickerProviderStateMixin {
|
||||
int _selectedTab = 0;
|
||||
late AnimationController _tabAnimationController;
|
||||
late AnimationController _contentAnimationController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabAnimationController = AnimationController(
|
||||
duration: AnimationDuration.microInteraction,
|
||||
vsync: this,
|
||||
);
|
||||
_contentAnimationController = AnimationController(
|
||||
duration: AnimationDuration.pageTransition,
|
||||
vsync: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabAnimationController.dispose();
|
||||
_contentAnimationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ErrorBoundary(
|
||||
child: Scaffold(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
appBar: _buildAppBar(),
|
||||
body: Column(
|
||||
children: [
|
||||
// Enhanced tab selector with animations
|
||||
_buildTabSelector().animate().fadeIn(
|
||||
duration: AnimationDuration.fast,
|
||||
delay: AnimationDelay.short,
|
||||
),
|
||||
|
||||
// Animated content
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: AnimationDuration.pageTransition,
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position:
|
||||
Tween<Offset>(
|
||||
begin: const Offset(0.05, 0),
|
||||
end: Offset.zero,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: AnimationCurves.pageTransition,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _selectedTab == 0
|
||||
? _buildRecentFiles()
|
||||
: _buildKnowledgeBase(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar() {
|
||||
return AppBar(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
elevation: Elevation.none,
|
||||
automaticallyImplyLeading: false,
|
||||
toolbarHeight: TouchTarget.appBar,
|
||||
titleSpacing: 0.0,
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Files',
|
||||
style: context.conduitTheme.headingSmall?.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
// Enhanced upload button with proper touch target
|
||||
Container(
|
||||
width: TouchTarget.iconButton,
|
||||
height: TouchTarget.iconButton,
|
||||
margin: const EdgeInsets.only(right: Spacing.screenPadding),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.button),
|
||||
onTap: _showUploadOptions,
|
||||
child: Icon(
|
||||
UiUtils.addIcon,
|
||||
color: context.conduitTheme.iconPrimary,
|
||||
size: IconSize.button,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabSelector() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.pagePadding,
|
||||
vertical: Spacing.sm,
|
||||
),
|
||||
padding: const EdgeInsets.all(Spacing.xs),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.card),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.cardBorder,
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
boxShadow: ConduitShadows.card,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTabButton(
|
||||
index: 0,
|
||||
label: 'Recent Files',
|
||||
isSelected: _selectedTab == 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Expanded(
|
||||
child: _buildTabButton(
|
||||
index: 1,
|
||||
label: 'Knowledge Base',
|
||||
isSelected: _selectedTab == 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabButton({
|
||||
required int index,
|
||||
required String label,
|
||||
required bool isSelected,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() => _selectedTab = index);
|
||||
_tabAnimationController.forward(from: 0);
|
||||
_contentAnimationController.forward(from: 0);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: AnimationDuration.microInteraction,
|
||||
curve: AnimationCurves.buttonPress,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: Spacing.buttonPadding,
|
||||
horizontal: Spacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? context.conduitTheme.buttonPrimary
|
||||
: context.conduitTheme.surfaceBackground.withValues(
|
||||
alpha: Alpha.hover,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.button),
|
||||
boxShadow: isSelected ? ConduitShadows.button : null,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: context.conduitTheme.label?.copyWith(
|
||||
color: isSelected
|
||||
? context.conduitTheme.textInverse
|
||||
: context.conduitTheme.textSecondary,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecentFiles() {
|
||||
return Container(
|
||||
key: const ValueKey('recent_files'),
|
||||
padding: const EdgeInsets.all(Spacing.pagePadding),
|
||||
child: ImprovedEmptyState(
|
||||
icon: UiUtils.platformIcon(
|
||||
ios: CupertinoIcons.doc,
|
||||
android: Icons.description_outlined,
|
||||
),
|
||||
title: 'No files yet',
|
||||
subtitle:
|
||||
'Upload documents to reference in your conversations with Conduit',
|
||||
onAction: _showUploadOptions,
|
||||
actionLabel: 'Upload your first file',
|
||||
showAnimation: true,
|
||||
),
|
||||
).animate().fadeIn(
|
||||
duration: AnimationDuration.messageAppear,
|
||||
delay: AnimationDelay.short,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKnowledgeBase() {
|
||||
return Container(
|
||||
key: const ValueKey('knowledge_base'),
|
||||
padding: const EdgeInsets.all(Spacing.pagePadding),
|
||||
child: ImprovedEmptyState(
|
||||
icon: UiUtils.platformIcon(
|
||||
ios: CupertinoIcons.book,
|
||||
android: Icons.library_books,
|
||||
),
|
||||
title: 'Knowledge base is empty',
|
||||
subtitle: 'Create collections of related documents for easy reference',
|
||||
onAction: _showKnowledgeBaseOptions,
|
||||
actionLabel: 'Create knowledge base',
|
||||
showAnimation: true,
|
||||
),
|
||||
).animate().fadeIn(
|
||||
duration: AnimationDuration.messageAppear,
|
||||
delay: AnimationDelay.short,
|
||||
);
|
||||
}
|
||||
|
||||
void _showUploadOptions() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _buildUploadModal(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadModal() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(AppBorderRadius.modal),
|
||||
topRight: Radius.circular(AppBorderRadius.modal),
|
||||
),
|
||||
boxShadow: ConduitShadows.modal,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Enhanced handle bar
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: Spacing.sm),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.dividerColor,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
),
|
||||
),
|
||||
|
||||
// Header with enhanced typography
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(Spacing.modalPadding),
|
||||
child: Text(
|
||||
'Upload File',
|
||||
style: context.conduitTheme.headingSmall?.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Enhanced upload options
|
||||
_buildUploadOption(
|
||||
icon: UiUtils.platformIcon(
|
||||
ios: CupertinoIcons.camera,
|
||||
android: Icons.camera_alt,
|
||||
),
|
||||
title: 'Take Photo',
|
||||
subtitle: 'Capture a document or image',
|
||||
onTap: () => _handleUploadOption('camera'),
|
||||
),
|
||||
_buildUploadOption(
|
||||
icon: UiUtils.platformIcon(
|
||||
ios: CupertinoIcons.photo,
|
||||
android: Icons.photo_library,
|
||||
),
|
||||
title: 'Photo Library',
|
||||
subtitle: 'Choose from your photos',
|
||||
onTap: () => _handleUploadOption('gallery'),
|
||||
),
|
||||
_buildUploadOption(
|
||||
icon: UiUtils.platformIcon(
|
||||
ios: CupertinoIcons.doc,
|
||||
android: Icons.description,
|
||||
),
|
||||
title: 'Document',
|
||||
subtitle: 'PDF, Word, or text file',
|
||||
onTap: () => _handleUploadOption('document'),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.modalPadding),
|
||||
],
|
||||
),
|
||||
),
|
||||
).animate().slide(
|
||||
duration: AnimationDuration.modalPresentation,
|
||||
curve: AnimationCurves.modalPresentation,
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadOption({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.modalPadding,
|
||||
vertical: Spacing.xs,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.card),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(Spacing.listItemPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.card),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.cardBorder,
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Enhanced icon container
|
||||
Container(
|
||||
width: IconSize.avatar,
|
||||
height: IconSize.avatar,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.buttonPrimary.withValues(
|
||||
alpha: Alpha.highlight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.avatar),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: context.conduitTheme.buttonPrimary,
|
||||
size: IconSize.medium,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
// Enhanced text content
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: context.conduitTheme.bodyLarge?.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Text(
|
||||
subtitle,
|
||||
style: context.conduitTheme.caption?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
UiUtils.platformIcon(
|
||||
ios: CupertinoIcons.chevron_right,
|
||||
android: Icons.chevron_right,
|
||||
),
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
size: IconSize.small,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
).animate().fadeIn(
|
||||
duration: AnimationDuration.fast,
|
||||
delay: AnimationDelay.staggeredDelay,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleUploadOption(String type) {
|
||||
NavigationService.goBack();
|
||||
UiUtils.showMessage(context, 'File upload for $type is coming soon!');
|
||||
}
|
||||
|
||||
void _showKnowledgeBaseOptions() {
|
||||
UiUtils.showMessage(context, 'Knowledge base creation is coming soon!');
|
||||
}
|
||||
}
|
||||
1362
lib/features/navigation/views/chats_list_page.dart
Normal file
1362
lib/features/navigation/views/chats_list_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
25
lib/features/navigation/views/splash_launcher_page.dart
Normal file
25
lib/features/navigation/views/splash_launcher_page.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
|
||||
class SplashLauncherPage extends StatelessWidget {
|
||||
const SplashLauncherPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
context.conduitTheme.loadingIndicator,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
288
lib/features/onboarding/views/onboarding_sheet.dart
Normal file
288
lib/features/onboarding/views/onboarding_sheet.dart
Normal file
@@ -0,0 +1,288 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
|
||||
class OnboardingSheet extends StatefulWidget {
|
||||
const OnboardingSheet({super.key});
|
||||
|
||||
@override
|
||||
State<OnboardingSheet> createState() => _OnboardingSheetState();
|
||||
}
|
||||
|
||||
class _OnboardingSheetState extends State<OnboardingSheet> {
|
||||
final PageController _controller = PageController();
|
||||
int _index = 0;
|
||||
|
||||
final List<_OnboardingPage> _pages = const [
|
||||
_OnboardingPage(
|
||||
title: 'Start a conversation',
|
||||
subtitle:
|
||||
'Choose a model, then type below to begin. Tap New Chat anytime.',
|
||||
icon: CupertinoIcons.chat_bubble_2,
|
||||
bullets: [
|
||||
'Tap the model name in the top bar to switch models',
|
||||
'Use New Chat to reset context',
|
||||
],
|
||||
),
|
||||
_OnboardingPage(
|
||||
title: 'Attach context',
|
||||
subtitle: 'Ground responses by adding files or images.',
|
||||
icon: CupertinoIcons.doc_on_doc,
|
||||
bullets: ['Files: PDFs, docs, datasets', 'Images: photos or screenshots'],
|
||||
),
|
||||
_OnboardingPage(
|
||||
title: 'Speak naturally',
|
||||
subtitle: 'Tap the mic to dictate with live waveform feedback.',
|
||||
icon: CupertinoIcons.mic_fill,
|
||||
bullets: [
|
||||
'Stop anytime; partial text is preserved',
|
||||
'Great for quick notes or long prompts',
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
void _next() {
|
||||
if (_index < _pages.length - 1) {
|
||||
_controller.nextPage(
|
||||
duration: AnimationDuration.fast,
|
||||
curve: AnimationCurves.easeInOut,
|
||||
);
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final height = MediaQuery.of(context).size.height;
|
||||
final isSmall = height < 720;
|
||||
return Container(
|
||||
height: height * 0.7,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(AppBorderRadius.modal),
|
||||
),
|
||||
boxShadow: ConduitShadows.modal,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Column(
|
||||
children: [
|
||||
// Handle bar
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: Spacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.dividerColor,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: _pages.length,
|
||||
onPageChanged: (i) => setState(() => _index = i),
|
||||
itemBuilder: (context, i) {
|
||||
final page = _pages[i];
|
||||
final content = _IllustratedPage(page: page);
|
||||
return isSmall
|
||||
? SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.lg,
|
||||
),
|
||||
child: content,
|
||||
)
|
||||
: content;
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.md),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(_pages.length, (i) {
|
||||
final active = i == _index;
|
||||
return AnimatedContainer(
|
||||
duration: AnimationDuration.fast,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
height: 6,
|
||||
width: active ? 20 : 6,
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? context.conduitTheme.buttonPrimary
|
||||
: context.conduitTheme.dividerColor,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.badge,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.lg),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(
|
||||
'Skip',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: _next,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: context.conduitTheme.buttonPrimary,
|
||||
foregroundColor: context.conduitTheme.buttonPrimaryText,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.lg,
|
||||
vertical: Spacing.sm,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.button,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(_index == _pages.length - 1 ? 'Done' : 'Next'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OnboardingPage {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final List<String>? bullets;
|
||||
const _OnboardingPage({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
this.bullets,
|
||||
});
|
||||
}
|
||||
|
||||
class _IllustratedPage extends StatelessWidget {
|
||||
final _OnboardingPage page;
|
||||
const _IllustratedPage({required this.page});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Aurora blob illustration
|
||||
SizedBox(
|
||||
height: 160,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned(top: 10, left: 24, child: _blob(context, 90, 0.18)),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 16,
|
||||
child: _blob(context, 130, 0.12),
|
||||
),
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.buttonPrimary,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.avatar),
|
||||
boxShadow: ConduitShadows.glow,
|
||||
),
|
||||
child: Icon(page.icon, color: context.conduitTheme.textInverse),
|
||||
).animate().scale(duration: AnimationDuration.fast),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.lg),
|
||||
Text(
|
||||
page.title,
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.headlineMedium,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: context.conduitTheme.textPrimary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
page.subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (page.bullets != null && page.bullets!.isNotEmpty) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: page.bullets!
|
||||
.map(
|
||||
(b) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.lg,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.only(top: 8, right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.buttonPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
b,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.bodyMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _blob(BuildContext context, double size, double alpha) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: context.conduitTheme.buttonPrimary.withValues(alpha: alpha),
|
||||
boxShadow: ConduitShadows.glow,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
468
lib/features/profile/views/profile_page.dart
Normal file
468
lib/features/profile/views/profile_page.dart
Normal file
@@ -0,0 +1,468 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import '../../../core/widgets/error_boundary.dart';
|
||||
import '../../../shared/widgets/improved_loading_states.dart';
|
||||
|
||||
import '../../../shared/utils/ui_utils.dart';
|
||||
import '../../../shared/widgets/conduit_components.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../auth/providers/unified_auth_providers.dart';
|
||||
|
||||
/// Profile page (You tab) showing user info and main actions
|
||||
/// Enhanced with production-grade design tokens for better cohesion
|
||||
class ProfilePage extends ConsumerWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
return ErrorBoundary(
|
||||
child: user.when(
|
||||
data: (userData) => Scaffold(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
appBar: AppBar(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
elevation: Elevation.none,
|
||||
automaticallyImplyLeading: false,
|
||||
toolbarHeight: kToolbarHeight,
|
||||
titleSpacing: 0.0,
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'You',
|
||||
style: context.conduitTheme.headingSmall?.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(Spacing.pagePadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Profile Header - Enhanced with better spacing and animations
|
||||
_buildProfileHeader(userData)
|
||||
.animate()
|
||||
.fadeIn(duration: AnimationDuration.pageTransition)
|
||||
.slideY(
|
||||
begin: 0.1,
|
||||
end: 0,
|
||||
curve: AnimationCurves.pageTransition,
|
||||
),
|
||||
const SizedBox(height: Spacing.sectionGap),
|
||||
|
||||
// Account Section - Enhanced with improved spacing
|
||||
_buildAccountSection(context, ref)
|
||||
.animate()
|
||||
.fadeIn(
|
||||
delay: AnimationDelay.short,
|
||||
duration: AnimationDuration.pageTransition,
|
||||
)
|
||||
.slideY(
|
||||
begin: 0.1,
|
||||
end: 0,
|
||||
curve: AnimationCurves.pageTransition,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
loading: () => Scaffold(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
appBar: AppBar(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
elevation: Elevation.none,
|
||||
automaticallyImplyLeading: false,
|
||||
title: Text(
|
||||
'You',
|
||||
style: context.conduitTheme.headingSmall?.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: const Center(
|
||||
child: ImprovedLoadingState(message: 'Loading profile...'),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => Scaffold(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
appBar: AppBar(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
elevation: Elevation.none,
|
||||
automaticallyImplyLeading: false,
|
||||
title: Text(
|
||||
'You',
|
||||
style: context.conduitTheme.headingSmall?.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Center(
|
||||
child: ImprovedEmptyState(
|
||||
title: 'Unable to load profile',
|
||||
subtitle: 'Please check your connection and try again',
|
||||
icon: UiUtils.platformIcon(
|
||||
ios: CupertinoIcons.exclamationmark_triangle,
|
||||
android: Icons.error_outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileHeader(dynamic user) {
|
||||
return Builder(
|
||||
builder: (context) => ConduitCard(
|
||||
padding: const EdgeInsets.all(Spacing.cardPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
// Enhanced avatar with better sizing and shadows
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.avatar),
|
||||
boxShadow: ConduitShadows.card,
|
||||
),
|
||||
child: ConduitAvatar(
|
||||
size: IconSize.avatar,
|
||||
text: user?.name?.substring(0, 1) ?? 'U',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
user?.name ?? 'User',
|
||||
style: context.conduitTheme.headingMedium?.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Text(
|
||||
user?.email ?? 'No email',
|
||||
style: context.conduitTheme.bodyMedium?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
// Enhanced status badge with better styling
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.sm,
|
||||
vertical: Spacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.success.withValues(
|
||||
alpha: Alpha.badgeBackground,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.badge,
|
||||
),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.success.withValues(
|
||||
alpha: Alpha.avatarBorder,
|
||||
),
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.success,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Text(
|
||||
'Active',
|
||||
style: context.conduitTheme.label?.copyWith(
|
||||
color: context.conduitTheme.success,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAccountSection(BuildContext context, WidgetRef ref) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Account',
|
||||
style: context.conduitTheme.headingSmall?.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
ConduitCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildThemeToggleTile(context, ref),
|
||||
Divider(color: context.conduitTheme.dividerColor, height: 1),
|
||||
_buildAboutTile(context),
|
||||
Divider(color: context.conduitTheme.dividerColor, height: 1),
|
||||
_buildAccountOption(
|
||||
icon: UiUtils.platformIcon(
|
||||
ios: CupertinoIcons.square_arrow_left,
|
||||
android: Icons.logout,
|
||||
),
|
||||
title: 'Sign Out',
|
||||
subtitle: 'End your session',
|
||||
onTap: () => _signOut(context, ref),
|
||||
isDestructive: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAccountOption({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
bool isDestructive = false,
|
||||
}) {
|
||||
return Builder(
|
||||
builder: (context) => ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.listItemPadding,
|
||||
vertical: Spacing.sm,
|
||||
),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(Spacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: isDestructive
|
||||
? context.conduitTheme.error.withValues(alpha: Alpha.highlight)
|
||||
: context.conduitTheme.buttonPrimary.withValues(
|
||||
alpha: Alpha.highlight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.small),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isDestructive
|
||||
? context.conduitTheme.error
|
||||
: context.conduitTheme.buttonPrimary,
|
||||
size: IconSize.medium,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: context.conduitTheme.bodyLarge?.copyWith(
|
||||
color: isDestructive
|
||||
? context.conduitTheme.error
|
||||
: context.conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
trailing: Icon(
|
||||
UiUtils.platformIcon(
|
||||
ios: CupertinoIcons.chevron_right,
|
||||
android: Icons.chevron_right,
|
||||
),
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
size: IconSize.small,
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemeToggleTile(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
final isDark = themeMode == ThemeMode.dark;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.listItemPadding,
|
||||
vertical: Spacing.sm,
|
||||
),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(Spacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.buttonPrimary.withValues(
|
||||
alpha: Alpha.highlight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.small),
|
||||
),
|
||||
child: Icon(
|
||||
UiUtils.platformIcon(
|
||||
ios: CupertinoIcons.moon_stars,
|
||||
android: Icons.dark_mode,
|
||||
),
|
||||
color: context.conduitTheme.buttonPrimary,
|
||||
size: IconSize.medium,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
'Dark Mode',
|
||||
style: context.conduitTheme.bodyLarge?.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
isDark ? 'Currently using Dark theme' : 'Currently using Light theme',
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
trailing: Switch.adaptive(
|
||||
value: isDark,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(themeModeProvider.notifier)
|
||||
.setTheme(value ? ThemeMode.dark : ThemeMode.light);
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
final newValue = !isDark;
|
||||
ref
|
||||
.read(themeModeProvider.notifier)
|
||||
.setTheme(newValue ? ThemeMode.dark : ThemeMode.light);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAboutTile(BuildContext context) {
|
||||
return _buildAccountOption(
|
||||
icon: UiUtils.platformIcon(
|
||||
ios: CupertinoIcons.info,
|
||||
android: Icons.info_outline,
|
||||
),
|
||||
title: 'About App',
|
||||
subtitle: 'Conduit information and links',
|
||||
onTap: () => _showAboutDialog(context),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showAboutDialog(BuildContext context) async {
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
// Update dialog with dynamic version each time
|
||||
// GitHub repo URL source of truth
|
||||
const githubUrl = 'https://github.com/cogwheel0/conduit';
|
||||
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return AlertDialog(
|
||||
backgroundColor: ctx.conduitTheme.surfaceBackground,
|
||||
title: Text(
|
||||
'About Conduit',
|
||||
style: ctx.conduitTheme.headingSmall?.copyWith(
|
||||
color: ctx.conduitTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Version: ${info.version} (${info.buildNumber})',
|
||||
style: ctx.conduitTheme.bodyMedium?.copyWith(
|
||||
color: ctx.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
InkWell(
|
||||
onTap: () => launchUrlString(
|
||||
githubUrl,
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
UiUtils.platformIcon(
|
||||
ios: CupertinoIcons.link,
|
||||
android: Icons.link,
|
||||
),
|
||||
size: IconSize.small,
|
||||
color: ctx.conduitTheme.buttonPrimary,
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Text(
|
||||
'GitHub Repository',
|
||||
style: ctx.conduitTheme.bodyMedium?.copyWith(
|
||||
color: ctx.conduitTheme.buttonPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
UiUtils.showMessage(context, 'Unable to load app info');
|
||||
}
|
||||
}
|
||||
|
||||
void _signOut(BuildContext context, WidgetRef ref) async {
|
||||
final confirm = await UiUtils.showConfirmationDialog(
|
||||
context,
|
||||
title: 'Sign out?',
|
||||
message: 'You\'ll need to sign in again to continue',
|
||||
confirmText: 'Sign out',
|
||||
isDestructive: true,
|
||||
);
|
||||
|
||||
if (confirm) {
|
||||
await ref.read(logoutActionProvider);
|
||||
}
|
||||
}
|
||||
}
|
||||
56
lib/features/server/providers/server_providers.dart
Normal file
56
lib/features/server/providers/server_providers.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
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);
|
||||
});
|
||||
278
lib/features/settings/views/accessibility_settings_page.dart
Normal file
278
lib/features/settings/views/accessibility_settings_page.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../shared/widgets/conduit_components.dart';
|
||||
import '../../../core/services/settings_service.dart';
|
||||
import '../../../core/services/enhanced_accessibility_service.dart';
|
||||
import '../../../core/services/platform_service.dart';
|
||||
|
||||
/// Accessibility settings page with WCAG 2.2 AA compliance controls
|
||||
class AccessibilitySettingsPage extends ConsumerWidget {
|
||||
const AccessibilitySettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
appBar: PlatformService.createPlatformAppBar(
|
||||
title: 'Accessibility',
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
foregroundColor: context.conduitTheme.textPrimary,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(context, 'Motion & Animation'),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
// Reduce Motion Toggle
|
||||
ConduitCard(
|
||||
child: EnhancedAccessibilityService.createAccessibleSwitch(
|
||||
value: settings.reduceMotion,
|
||||
onChanged: (value) {
|
||||
ref.read(appSettingsProvider.notifier).setReduceMotion(value);
|
||||
EnhancedAccessibilityService.announceSuccess(
|
||||
value
|
||||
? 'Reduced motion enabled'
|
||||
: 'Reduced motion disabled',
|
||||
);
|
||||
},
|
||||
label: 'Reduce Motion',
|
||||
description:
|
||||
'Minimize animations and transitions for better focus and reduced vestibular disturbance',
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
// Animation Speed Slider
|
||||
if (!settings.reduceMotion) ...[
|
||||
ConduitCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Animation Speed',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
'Adjust the speed of animations and transitions',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.labelLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
EnhancedAccessibilityService.createAccessibleSlider(
|
||||
value: settings.animationSpeed,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(appSettingsProvider.notifier)
|
||||
.setAnimationSpeed(value);
|
||||
},
|
||||
label: 'Animation speed',
|
||||
min: 0.5,
|
||||
max: 2.0,
|
||||
divisions: 6,
|
||||
valueFormatter: (value) {
|
||||
if (value < 0.75) return 'Slow';
|
||||
if (value < 1.25) return 'Normal';
|
||||
return 'Fast';
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
],
|
||||
|
||||
const SizedBox(height: Spacing.lg),
|
||||
_buildSectionHeader(context, 'Visual & Text'),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
// Large Text Toggle
|
||||
ConduitCard(
|
||||
child: EnhancedAccessibilityService.createAccessibleSwitch(
|
||||
value: settings.largeText,
|
||||
onChanged: (value) {
|
||||
ref.read(appSettingsProvider.notifier).setLargeText(value);
|
||||
EnhancedAccessibilityService.announceSuccess(
|
||||
value ? 'Large text enabled' : 'Large text disabled',
|
||||
);
|
||||
},
|
||||
label: 'Large Text',
|
||||
description:
|
||||
'Increase text size throughout the app for better readability',
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
// High Contrast Toggle
|
||||
ConduitCard(
|
||||
child: EnhancedAccessibilityService.createAccessibleSwitch(
|
||||
value: settings.highContrast,
|
||||
onChanged: (value) {
|
||||
ref.read(appSettingsProvider.notifier).setHighContrast(value);
|
||||
EnhancedAccessibilityService.announceSuccess(
|
||||
value ? 'High contrast enabled' : 'High contrast disabled',
|
||||
);
|
||||
},
|
||||
label: 'High Contrast',
|
||||
description:
|
||||
'Increase contrast between text and background colors',
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.lg),
|
||||
_buildSectionHeader(context, 'Interaction'),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
// Haptic Feedback Toggle
|
||||
ConduitCard(
|
||||
child: EnhancedAccessibilityService.createAccessibleSwitch(
|
||||
value: settings.hapticFeedback,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(appSettingsProvider.notifier)
|
||||
.setHapticFeedback(value);
|
||||
if (value) {
|
||||
PlatformService.hapticFeedback(type: HapticType.success);
|
||||
}
|
||||
EnhancedAccessibilityService.announceSuccess(
|
||||
value
|
||||
? 'Haptic feedback enabled'
|
||||
: 'Haptic feedback disabled',
|
||||
);
|
||||
},
|
||||
label: 'Haptic Feedback',
|
||||
description:
|
||||
'Feel vibrations when interacting with buttons and controls',
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.lg),
|
||||
_buildSectionHeader(context, 'System Integration'),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
// System Settings Info Card
|
||||
ConduitCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: context.conduitTheme.buttonPrimary,
|
||||
size: IconSize.md,
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
Text(
|
||||
'System Settings',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
'Conduit automatically respects your device\'s accessibility settings, including:',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.labelLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
...[
|
||||
'• Reduce Motion (iOS/Android)',
|
||||
'• VoiceOver/TalkBack screen readers',
|
||||
'• Dynamic Type/Font scale',
|
||||
'• Color inversion and filters',
|
||||
].map(
|
||||
(item) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
item,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.labelLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.lg),
|
||||
|
||||
// Reset to Defaults Button
|
||||
ConduitButton(
|
||||
text: 'Reset to Defaults',
|
||||
onPressed: () => _showResetDialog(context, ref),
|
||||
isSecondary: true,
|
||||
width: double.infinity,
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.xl),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(BuildContext context, String title) {
|
||||
return EnhancedAccessibilityService.createAccessibleText(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.buttonPrimary,
|
||||
fontSize: AppTypography.headlineSmall,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
isHeader: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showResetDialog(BuildContext context, WidgetRef ref) async {
|
||||
final confirmed = await PlatformService.showPlatformAlert(
|
||||
context: context,
|
||||
title: 'Reset Accessibility Settings',
|
||||
content:
|
||||
'This will reset all accessibility preferences to their default values. Are you sure?',
|
||||
confirmText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
isDestructive: true,
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await ref.read(appSettingsProvider.notifier).resetToDefaults();
|
||||
EnhancedAccessibilityService.announceSuccess(
|
||||
'Accessibility settings reset to defaults',
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Accessibility settings reset to defaults'),
|
||||
backgroundColor: context.conduitTheme.buttonPrimary,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
810
lib/features/settings/views/searchable_settings_page.dart
Normal file
810
lib/features/settings/views/searchable_settings_page.dart
Normal file
@@ -0,0 +1,810 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../../../core/widgets/error_boundary.dart';
|
||||
import '../../../core/services/navigation_service.dart';
|
||||
import '../../../shared/widgets/themed_dialogs.dart';
|
||||
import '../../../core/services/focus_management_service.dart';
|
||||
import '../../../shared/widgets/improved_loading_states.dart';
|
||||
import '../../../shared/widgets/conduit_components.dart';
|
||||
import '../../../core/models/user_settings.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../../shared/utils/platform_utils.dart';
|
||||
|
||||
enum ThemeVariant { conduit }
|
||||
|
||||
// Settings search provider
|
||||
final settingsSearchQueryProvider = StateProvider<String>((ref) => '');
|
||||
|
||||
// Setting item model
|
||||
class SettingItem {
|
||||
final String id;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final IconData icon;
|
||||
final String category;
|
||||
final List<String> searchTerms;
|
||||
final VoidCallback? onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
SettingItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.icon,
|
||||
required this.category,
|
||||
required this.searchTerms,
|
||||
this.onTap,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
bool matchesSearch(String query) {
|
||||
final lowerQuery = query.toLowerCase();
|
||||
return title.toLowerCase().contains(lowerQuery) ||
|
||||
(subtitle?.toLowerCase().contains(lowerQuery) ?? false) ||
|
||||
category.toLowerCase().contains(lowerQuery) ||
|
||||
searchTerms.any((term) => term.toLowerCase().contains(lowerQuery));
|
||||
}
|
||||
}
|
||||
|
||||
class SearchableSettingsPage extends ConsumerStatefulWidget {
|
||||
const SearchableSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SearchableSettingsPage> createState() =>
|
||||
_SearchableSettingsPageState();
|
||||
}
|
||||
|
||||
class _SearchableSettingsPageState
|
||||
extends ConsumerState<SearchableSettingsPage> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
late FocusNode _searchFocusNode;
|
||||
bool _isSearching = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchFocusNode = FocusManagementService.registerFocusNode(
|
||||
'settings_search',
|
||||
debugLabel: 'Settings Search Field',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
FocusManagementService.disposeFocusNode('settings_search');
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<SettingItem> _buildSettingItems(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
// Single Conduit theme variant in this refactor; kept provider for future use
|
||||
final userSettingsAsync = ref.watch(userSettingsProvider);
|
||||
final userSettings = userSettingsAsync.when(
|
||||
data: (data) => data,
|
||||
loading: () => null,
|
||||
error: (_, _) => null,
|
||||
);
|
||||
|
||||
return [
|
||||
// Profile & Account
|
||||
SettingItem(
|
||||
id: 'profile',
|
||||
title: 'Profile',
|
||||
subtitle: 'Manage your account details',
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.person_circle
|
||||
: Icons.account_circle,
|
||||
category: 'Profile & Account',
|
||||
searchTerms: ['account', 'user', 'name', 'email', 'avatar'],
|
||||
onTap: () => _navigateToProfile(context),
|
||||
),
|
||||
SettingItem(
|
||||
id: 'server',
|
||||
title: 'Server Connection',
|
||||
subtitle: 'Manage Open WebUI servers',
|
||||
icon: Platform.isIOS ? CupertinoIcons.cloud : Icons.cloud,
|
||||
category: 'Profile & Account',
|
||||
searchTerms: ['server', 'connection', 'api', 'host', 'url'],
|
||||
onTap: () => _navigateToServerSettings(context),
|
||||
),
|
||||
SettingItem(
|
||||
id: 'sign-out',
|
||||
title: 'Sign Out',
|
||||
subtitle: 'Sign out of your account',
|
||||
icon: Platform.isIOS ? CupertinoIcons.square_arrow_right : Icons.logout,
|
||||
category: 'Profile & Account',
|
||||
searchTerms: ['logout', 'signout', 'exit'],
|
||||
onTap: () => _handleSignOut(context, ref),
|
||||
),
|
||||
|
||||
// Appearance
|
||||
SettingItem(
|
||||
id: 'theme',
|
||||
title: 'Theme',
|
||||
subtitle: 'Choose light or dark theme',
|
||||
icon: Platform.isIOS ? CupertinoIcons.moon_circle : Icons.dark_mode,
|
||||
category: 'Appearance',
|
||||
searchTerms: ['dark', 'light', 'mode', 'appearance', 'color'],
|
||||
trailing: _buildThemeSelector(ref, themeMode),
|
||||
),
|
||||
// Removed variant switching; Conduit brand theme is the single source of truth
|
||||
SettingItem(
|
||||
id: 'text-size',
|
||||
title: 'Text Size',
|
||||
subtitle: 'Adjust font size for better readability',
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.textformat_size
|
||||
: Icons.text_fields,
|
||||
category: 'Appearance',
|
||||
searchTerms: ['font', 'size', 'text', 'readability', 'accessibility'],
|
||||
onTap: () => _showTextSizeDialog(context),
|
||||
),
|
||||
|
||||
// Chat & AI
|
||||
SettingItem(
|
||||
id: 'stream-responses',
|
||||
title: 'Stream Responses',
|
||||
subtitle: 'See responses as they\'re generated',
|
||||
icon: Platform.isIOS ? CupertinoIcons.bolt : Icons.flash_on,
|
||||
category: 'Chat & AI',
|
||||
searchTerms: ['stream', 'real-time', 'live', 'responses'],
|
||||
trailing: PlatformUtils.createSwitch(
|
||||
value: userSettings?.streamResponses ?? true,
|
||||
onChanged: (value) => _updateSetting(ref, 'streamResponses', value),
|
||||
),
|
||||
),
|
||||
SettingItem(
|
||||
id: 'save-conversations',
|
||||
title: 'Save Conversations',
|
||||
subtitle: 'Keep chat history between sessions',
|
||||
icon: Platform.isIOS ? CupertinoIcons.archivebox : Icons.save,
|
||||
category: 'Chat & AI',
|
||||
searchTerms: ['save', 'history', 'conversations', 'chat', 'archive'],
|
||||
trailing: PlatformUtils.createSwitch(
|
||||
value: userSettings?.saveConversations ?? true,
|
||||
onChanged: (value) => _updateSetting(ref, 'saveConversations', value),
|
||||
),
|
||||
),
|
||||
SettingItem(
|
||||
id: 'web-search',
|
||||
title: 'Web Search',
|
||||
subtitle: 'Allow AI to search the web for information',
|
||||
icon: Platform.isIOS ? CupertinoIcons.globe : Icons.public,
|
||||
category: 'Chat & AI',
|
||||
searchTerms: ['web', 'search', 'internet', 'browse', 'online'],
|
||||
trailing: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final settings = ref.watch(userSettingsProvider);
|
||||
return settings.when(
|
||||
data: (userSettings) => PlatformUtils.createSwitch(
|
||||
value: userSettings.webSearchEnabled,
|
||||
onChanged: (value) =>
|
||||
_updateSetting(ref, 'webSearchEnabled', value),
|
||||
),
|
||||
loading: () =>
|
||||
const ImprovedLoadingState(message: 'Loading setting...'),
|
||||
error: (error, stackTrace) => PlatformUtils.createSwitch(
|
||||
value: false,
|
||||
onChanged: (value) =>
|
||||
_updateSetting(ref, 'webSearchEnabled', value),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SettingItem(
|
||||
id: 'model-selection',
|
||||
title: 'Default Model',
|
||||
subtitle: 'Choose your preferred AI model',
|
||||
icon: Platform.isIOS ? CupertinoIcons.cube : Icons.psychology,
|
||||
category: 'Chat & AI',
|
||||
searchTerms: ['model', 'ai', 'gpt', 'conduit', 'llm'],
|
||||
onTap: () => _showModelSelector(context),
|
||||
),
|
||||
|
||||
// Privacy & Security
|
||||
SettingItem(
|
||||
id: 'clear-history',
|
||||
title: 'Clear Chat History',
|
||||
subtitle: 'Delete all conversations',
|
||||
icon: Platform.isIOS ? CupertinoIcons.trash : Icons.delete_outline,
|
||||
category: 'Privacy & Security',
|
||||
searchTerms: ['clear', 'delete', 'history', 'privacy', 'remove'],
|
||||
onTap: () => _showClearHistoryDialog(context, ref),
|
||||
),
|
||||
SettingItem(
|
||||
id: 'export-data',
|
||||
title: 'Export Data',
|
||||
subtitle: 'Download your conversations',
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.square_arrow_down
|
||||
: Icons.download,
|
||||
category: 'Privacy & Security',
|
||||
searchTerms: ['export', 'download', 'backup', 'data'],
|
||||
onTap: () => _handleExportData(context),
|
||||
),
|
||||
|
||||
// Accessibility
|
||||
SettingItem(
|
||||
id: 'reduce-motion',
|
||||
title: 'Reduce Motion',
|
||||
subtitle: 'Minimize animations',
|
||||
icon: Platform.isIOS ? CupertinoIcons.slowmo : Icons.animation,
|
||||
category: 'Accessibility',
|
||||
searchTerms: ['motion', 'animation', 'reduce', 'accessibility'],
|
||||
trailing: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final settings = ref.watch(userSettingsProvider);
|
||||
return settings.when(
|
||||
data: (userSettings) => PlatformUtils.createSwitch(
|
||||
value: userSettings.reduceMotion,
|
||||
onChanged: (value) =>
|
||||
_updateSetting(ref, 'reduceMotion', value),
|
||||
),
|
||||
loading: () =>
|
||||
const ImprovedLoadingState(message: 'Loading setting...'),
|
||||
error: (error, stackTrace) => PlatformUtils.createSwitch(
|
||||
value: false,
|
||||
onChanged: (value) =>
|
||||
_updateSetting(ref, 'reduceMotion', value),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SettingItem(
|
||||
id: 'haptic-feedback',
|
||||
title: 'Haptic Feedback',
|
||||
subtitle: 'Vibration feedback for actions',
|
||||
icon: Platform.isIOS ? CupertinoIcons.hand_draw : Icons.vibration,
|
||||
category: 'Accessibility',
|
||||
searchTerms: ['haptic', 'vibration', 'feedback', 'touch'],
|
||||
trailing: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final settings = ref.watch(userSettingsProvider);
|
||||
return settings.when(
|
||||
data: (userSettings) => PlatformUtils.createSwitch(
|
||||
value: userSettings.hapticFeedback,
|
||||
onChanged: (value) =>
|
||||
_updateSetting(ref, 'hapticFeedback', value),
|
||||
),
|
||||
loading: () =>
|
||||
const ImprovedLoadingState(message: 'Loading setting...'),
|
||||
error: (error, stackTrace) => PlatformUtils.createSwitch(
|
||||
value: true,
|
||||
onChanged: (value) =>
|
||||
_updateSetting(ref, 'hapticFeedback', value),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// About
|
||||
SettingItem(
|
||||
id: 'version',
|
||||
title: 'App Version',
|
||||
subtitle: 'Conduit v1.0.0',
|
||||
icon: Platform.isIOS ? CupertinoIcons.info_circle : Icons.info_outline,
|
||||
category: 'About',
|
||||
searchTerms: ['version', 'about', 'info', 'conduit'],
|
||||
onTap: () => _showAboutDialog(context),
|
||||
),
|
||||
SettingItem(
|
||||
id: 'help',
|
||||
title: 'Help & Support',
|
||||
subtitle: 'Get assistance and report issues',
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.question_circle
|
||||
: Icons.help_outline,
|
||||
category: 'About',
|
||||
searchTerms: ['help', 'support', 'assistance', 'contact'],
|
||||
onTap: () => _navigateToHelp(context),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<SettingItem> _getFilteredSettings(BuildContext context, WidgetRef ref) {
|
||||
final searchQuery = ref.watch(settingsSearchQueryProvider);
|
||||
final allSettings = _buildSettingItems(context, ref);
|
||||
|
||||
if (searchQuery.isEmpty) {
|
||||
return allSettings;
|
||||
}
|
||||
|
||||
return allSettings
|
||||
.where((item) => item.matchesSearch(searchQuery))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Map<String, List<SettingItem>> _groupSettingsByCategory(
|
||||
List<SettingItem> settings,
|
||||
) {
|
||||
final grouped = <String, List<SettingItem>>{};
|
||||
|
||||
for (final setting in settings) {
|
||||
grouped.putIfAbsent(setting.category, () => []).add(setting);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filteredSettings = _getFilteredSettings(context, ref);
|
||||
final groupedSettings = _groupSettingsByCategory(filteredSettings);
|
||||
final categories = groupedSettings.keys.toList()..sort();
|
||||
|
||||
return ErrorBoundary(
|
||||
child: Scaffold(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: Elevation.none,
|
||||
title: _isSearching
|
||||
? _buildSearchBar()
|
||||
: Text(
|
||||
'Settings',
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.headlineMedium,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
leading: ConduitIconButton(
|
||||
icon: Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back,
|
||||
onPressed: () {
|
||||
if (_isSearching) {
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
_searchController.clear();
|
||||
ref.read(settingsSearchQueryProvider.notifier).state = '';
|
||||
});
|
||||
} else {
|
||||
NavigationService.goBack();
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (!_isSearching)
|
||||
ConduitIconButton(
|
||||
icon: Platform.isIOS ? CupertinoIcons.search : Icons.search,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
});
|
||||
_searchFocusNode.requestFocus();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: filteredSettings.isEmpty
|
||||
? _buildEmptySearchResults()
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final category = categories[index];
|
||||
final items = groupedSettings[category]!;
|
||||
|
||||
return _buildCategorySection(category, items);
|
||||
},
|
||||
),
|
||||
),
|
||||
), // Added closing parenthesis for ErrorBoundary
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchBar() {
|
||||
return TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search settings...',
|
||||
hintStyle: TextStyle(
|
||||
color: context.conduitTheme.inputPlaceholder,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(settingsSearchQueryProvider.notifier).state = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptySearchResults() {
|
||||
return ImprovedEmptyState(
|
||||
title: 'No settings found',
|
||||
subtitle: 'Try a different search term',
|
||||
icon: Platform.isIOS ? CupertinoIcons.search : Icons.search_off,
|
||||
showAnimation: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategorySection(String category, List<SettingItem> items) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Spacing.md,
|
||||
Spacing.md,
|
||||
Spacing.md,
|
||||
Spacing.sm,
|
||||
),
|
||||
child: Text(
|
||||
category,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.bodySmall,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: items.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
final isLast = index == items.length - 1;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildSettingTile(item),
|
||||
if (!isLast)
|
||||
Divider(
|
||||
height: 1,
|
||||
color: context.conduitTheme.dividerColor,
|
||||
indent: 56,
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingTile(SettingItem item) {
|
||||
final searchQuery = ref.watch(settingsSearchQueryProvider);
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: item.onTap,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
),
|
||||
child: Icon(
|
||||
item.icon,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
size: IconSize.md,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_highlightSearchText(item.title, searchQuery),
|
||||
if (item.subtitle != null) ...[
|
||||
const SizedBox(height: Spacing.xxs),
|
||||
_highlightSearchText(
|
||||
item.subtitle!,
|
||||
searchQuery,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.trailing != null) ...[
|
||||
const SizedBox(width: Spacing.sm),
|
||||
item.trailing!,
|
||||
] else if (item.onTap != null)
|
||||
Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.chevron_forward
|
||||
: Icons.chevron_right,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
size: IconSize.md,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _highlightSearchText(String text, String query, {TextStyle? style}) {
|
||||
if (query.isEmpty) {
|
||||
return Text(
|
||||
text,
|
||||
style:
|
||||
style ??
|
||||
TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final lowerText = text.toLowerCase();
|
||||
final lowerQuery = query.toLowerCase();
|
||||
final index = lowerText.indexOf(lowerQuery);
|
||||
|
||||
if (index == -1) {
|
||||
return Text(text, style: style);
|
||||
}
|
||||
|
||||
final before = text.substring(0, index);
|
||||
final match = text.substring(index, index + query.length);
|
||||
final after = text.substring(index + query.length);
|
||||
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
style:
|
||||
style ??
|
||||
TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
children: [
|
||||
TextSpan(text: before),
|
||||
TextSpan(
|
||||
text: match,
|
||||
style: TextStyle(
|
||||
backgroundColor: context.conduitTheme.buttonPrimary.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
TextSpan(text: after),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemeSelector(WidgetRef ref, ThemeMode themeMode) {
|
||||
return CupertinoSlidingSegmentedControl<ThemeMode>(
|
||||
groupValue: themeMode,
|
||||
children: const {
|
||||
ThemeMode.light: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'Light',
|
||||
style: TextStyle(fontSize: AppTypography.bodySmall),
|
||||
),
|
||||
),
|
||||
ThemeMode.dark: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'Dark',
|
||||
style: TextStyle(fontSize: AppTypography.bodySmall),
|
||||
),
|
||||
),
|
||||
ThemeMode.system: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'Auto',
|
||||
style: TextStyle(fontSize: AppTypography.bodySmall),
|
||||
),
|
||||
),
|
||||
},
|
||||
onValueChanged: (value) {
|
||||
if (value != null) {
|
||||
ref.read(themeModeProvider.notifier).setTheme(value);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Theme variant state removed; single Conduit theme in use
|
||||
|
||||
void _updateSetting(WidgetRef ref, String key, dynamic value) async {
|
||||
try {
|
||||
final currentSettings = await ref.read(userSettingsProvider.future);
|
||||
|
||||
// Create updated settings based on the key
|
||||
UserSettings updatedSettings;
|
||||
switch (key) {
|
||||
case 'webSearchEnabled':
|
||||
updatedSettings = currentSettings.copyWith(
|
||||
webSearchEnabled: value as bool,
|
||||
);
|
||||
break;
|
||||
case 'reduceMotion':
|
||||
updatedSettings = currentSettings.copyWith(
|
||||
reduceMotion: value as bool,
|
||||
);
|
||||
break;
|
||||
case 'hapticFeedback':
|
||||
updatedSettings = currentSettings.copyWith(
|
||||
hapticFeedback: value as bool,
|
||||
);
|
||||
break;
|
||||
case 'streamResponses':
|
||||
updatedSettings = currentSettings.copyWith(
|
||||
streamResponses: value as bool,
|
||||
);
|
||||
break;
|
||||
case 'saveConversations':
|
||||
updatedSettings = currentSettings.copyWith(
|
||||
saveConversations: value as bool,
|
||||
);
|
||||
break;
|
||||
case 'showReadReceipts':
|
||||
updatedSettings = currentSettings.copyWith(
|
||||
showReadReceipts: value as bool,
|
||||
);
|
||||
break;
|
||||
case 'enableNotifications':
|
||||
updatedSettings = currentSettings.copyWith(
|
||||
enableNotifications: value as bool,
|
||||
);
|
||||
break;
|
||||
case 'enableSounds':
|
||||
updatedSettings = currentSettings.copyWith(
|
||||
enableSounds: value as bool,
|
||||
);
|
||||
break;
|
||||
case 'shareUsageData':
|
||||
updatedSettings = currentSettings.copyWith(
|
||||
shareUsageData: value as bool,
|
||||
);
|
||||
break;
|
||||
case 'temperature':
|
||||
updatedSettings = currentSettings.copyWith(
|
||||
temperature: value as double,
|
||||
);
|
||||
break;
|
||||
case 'maxTokens':
|
||||
updatedSettings = currentSettings.copyWith(maxTokens: value as int);
|
||||
break;
|
||||
case 'fontSize':
|
||||
updatedSettings = currentSettings.copyWith(fontSize: value as double);
|
||||
break;
|
||||
case 'theme':
|
||||
updatedSettings = currentSettings.copyWith(theme: value as String);
|
||||
break;
|
||||
case 'density':
|
||||
updatedSettings = currentSettings.copyWith(density: value as String);
|
||||
break;
|
||||
case 'language':
|
||||
updatedSettings = currentSettings.copyWith(language: value as String);
|
||||
break;
|
||||
default:
|
||||
// Handle custom settings
|
||||
final customSettings = Map<String, dynamic>.from(
|
||||
currentSettings.customSettings,
|
||||
);
|
||||
customSettings[key] = value;
|
||||
updatedSettings = currentSettings.copyWith(
|
||||
customSettings: customSettings,
|
||||
);
|
||||
}
|
||||
|
||||
// Update settings on server
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api != null) {
|
||||
await api.updateUserSettings(updatedSettings.toJson());
|
||||
|
||||
// Invalidate the provider to refresh the UI
|
||||
ref.invalidate(userSettingsProvider);
|
||||
|
||||
// Show success message
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Setting updated'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Show error message
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to update setting: $e'),
|
||||
backgroundColor: context.conduitTheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToProfile(BuildContext context) {
|
||||
// TODO: Navigate to profile page
|
||||
}
|
||||
|
||||
void _navigateToServerSettings(BuildContext context) {
|
||||
NavigationService.navigateTo('/server-connection');
|
||||
}
|
||||
|
||||
void _handleSignOut(BuildContext context, WidgetRef ref) {
|
||||
// ignore: unawaited_futures
|
||||
ThemedDialogs.confirm(
|
||||
context,
|
||||
title: 'Sign Out',
|
||||
message: 'Are you sure you want to sign out?',
|
||||
confirmText: 'Sign Out',
|
||||
).then((confirmed) {
|
||||
if (confirmed) {
|
||||
// TODO: Implement proper logout functionality when auth service is available
|
||||
// ref.read(authServiceProvider.notifier).logout();
|
||||
NavigationService.navigateTo('/login', clearStack: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showTextSizeDialog(BuildContext context) {
|
||||
// TODO: Implement text size adjustment dialog
|
||||
}
|
||||
|
||||
void _showModelSelector(BuildContext context) {
|
||||
// TODO: Implement model selection dialog
|
||||
}
|
||||
|
||||
void _showClearHistoryDialog(BuildContext context, WidgetRef ref) {
|
||||
// TODO: Implement clear history dialog
|
||||
}
|
||||
|
||||
void _handleExportData(BuildContext context) {
|
||||
// TODO: Implement data export
|
||||
}
|
||||
|
||||
void _showAboutDialog(BuildContext context) {
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'Conduit',
|
||||
applicationVersion: '1.0.0',
|
||||
applicationLegalese: '© 2024 Conduit Team',
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToHelp(BuildContext context) {
|
||||
// TODO: Navigate to help page
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user