Files
iiEsaywebUIapp/lib/features/chat/widgets/conversation_search_widget.dart

739 lines
23 KiB
Dart
Raw Normal View History

2025-08-10 01:20:45 +05:30
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';
}
}
}