chore: initial release

This commit is contained in:
cogwheel0
2025-08-10 01:20:45 +05:30
commit 758615813f
218 changed files with 67743 additions and 0 deletions

View 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 }

View 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

File diff suppressed because it is too large Load Diff

View 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();
});

View 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();
});

View 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;
});

View 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();
});

File diff suppressed because it is too large Load Diff

View 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,
),
),
);
}
}

View 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());
},
);
}

View 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,
),
),
],
),
);
}
}

View 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}';
}
}
}

View 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';
}
}
}

View 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,
),
),
],
),
),
);
}
}

View 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(),
),
);
}
}

View 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')}';
}
}

View 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');
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View 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,
),
),
],
),
);
}
}

View 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),
),
),
);
}
}

View 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,
),
);
}
}
}
}

View 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!');
}
}

File diff suppressed because it is too large Load Diff

View 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,
),
),
),
),
);
}
}

View 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,
),
);
}
}

View 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);
}
}
}

View 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);
});

View 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,
),
);
}
}
}
}

View 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
}
}