main
dart 988 lines 28.9 KB
Raw
1 import 'dart:async';
2 import 'dart:math';
3
4 import 'package:flutter_riverpod/flutter_riverpod.dart';
5
6 import '../../../core/providers/storage_providers.dart';
7 import '../../../core/storage/entities.dart';
8 import '../../../objectbox.g.dart';
9 import '../../chat/utils/message_variants.dart';
10 import '../data/message_search_service.dart';
11 import '../data/models/message_search_hit.dart';
12 import '../data/models/conversation.dart';
13 import '../data/models/conversation_folder.dart';
14 import '../../personas/data/models/persona.dart';
15 import '../../personas/utils/persona_prompt_utils.dart';
16
17 final conversationsProvider =
18 AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(() {
19 return ConversationsNotifier();
20 });
21
22 class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
23 @override
24 Future<List<Conversation>> build() async {
25 return _loadAll();
26 }
27
28 Future<List<Conversation>> _loadAll() async {
29 if (!ref.mounted) return [];
30 final db = ref.read(databaseProvider);
31 return await db.store.runInTransactionAsync(
32 TxMode.read,
33 _loadConversationsInBackground,
34 null,
35 );
36 }
37
38 Future<void> _updateState() async {
39 final data = await _loadAll();
40 if (ref.mounted) {
41 state = AsyncData(data);
42 }
43 }
44
45 void _updateInState(Conversation updated) {
46 final conversations = state.value;
47 if (conversations == null) {
48 unawaited(_updateState());
49 return;
50 }
51 final updatedList = conversations
52 .map((c) => c.id == updated.id ? updated : c)
53 .toList();
54 updatedList.sort((a, b) {
55 if (a.isPinned != b.isPinned) return a.isPinned ? -1 : 1;
56 return b.updatedAt.compareTo(a.updatedAt);
57 });
58 if (ref.mounted) {
59 state = AsyncData(updatedList);
60 }
61 }
62
63 static List<Conversation> _loadConversationsInBackground(Store store, _) {
64 final convBox = store.box<ConversationEntity>();
65 final msgBox = store.box<MessageEntity>();
66 final entities = convBox.getAll();
67 final conversations = <Conversation>[];
68
69 for (final entity in entities) {
70 final domain = entity.toDomain();
71 final query = msgBox
72 .query(MessageEntity_.conversationUid.equals(domain.id))
73 .build();
74 final messages = query.find();
75 query.close();
76
77 // A flat `isActiveVariant` filter counts every message ever marked
78 // active within its own variant group, including messages hanging
79 // off a branch whose ancestor is no longer the active variant.
80 // Resolve the real connected timeline from the root instead.
81 final activeMessages = MessageVariants.resolveActiveTimeline(
82 messages.map((e) => e.toDomain()).toList(),
83 );
84 final count = activeMessages.length;
85 final chars = activeMessages.fold<int>(
86 0,
87 (sum, message) => sum + message.content.length,
88 );
89
90 conversations.add(
91 domain.copyWith(messageCount: count, characterCount: chars),
92 );
93 }
94
95 conversations.sort((a, b) {
96 if (a.isPinned != b.isPinned) {
97 return a.isPinned ? -1 : 1;
98 }
99 return b.updatedAt.compareTo(a.updatedAt);
100 });
101
102 return conversations;
103 }
104
105 Future<Conversation> createConversation({
106 String? title,
107 String? personaId,
108 String? systemPrompt,
109 String? serverId,
110 String? modelId,
111 bool? mcpEnabled,
112 bool isTemporary = false,
113 String? folderId,
114 }) async {
115 final db = ref.read(databaseProvider);
116 final now = DateTime.now();
117 final id = _generateUuid();
118
119 final conversation = Conversation(
120 id: id,
121 title: title ?? 'New Chat',
122 createdAt: now,
123 updatedAt: now,
124 isPinned: false,
125 personaId: personaId,
126 serverId: serverId,
127 modelId: modelId,
128 messageCount: 0,
129 lastMessagePreview: null,
130 systemPrompt: systemPrompt,
131 mcpEnabled: mcpEnabled,
132 isTemporary: isTemporary,
133 folderId: folderId,
134 );
135
136 db.conversationBox.put(ConversationEntity.fromDomain(conversation));
137 await _updateState();
138 return conversation;
139 }
140
141 Future<void> updateMcpEnabled(String id, bool enabled) async {
142 final db = ref.read(databaseProvider);
143 final conversations = state.value ?? [];
144 final existingIndex = conversations.indexWhere((c) => c.id == id);
145 if (existingIndex != -1) {
146 final existing = conversations[existingIndex];
147 final updated = existing.copyWith(
148 mcpEnabled: enabled,
149 updatedAt: DateTime.now(),
150 );
151
152 final query = db.conversationBox
153 .query(ConversationEntity_.id.equals(id))
154 .build();
155 final existingEntity = query.findFirst();
156 query.close();
157
158 final entity = ConversationEntity.fromDomain(updated);
159 if (existingEntity != null) {
160 entity.internalId = existingEntity.internalId;
161 }
162 db.conversationBox.put(entity);
163
164 _updateInState(updated);
165 }
166 }
167
168 Future<void> renameConversation(String id, String newTitle) async {
169 final db = ref.read(databaseProvider);
170 final conversations = state.value ?? [];
171 final existing = conversations.firstWhere(
172 (c) => c.id == id,
173 orElse: () => throw Exception('Conversation not found in state'),
174 );
175
176 // Renaming is a metadata edit, not conversation activity — don't bump
177 // updatedAt, since that's used as the "last modified" sort/section date.
178 final updated = existing.copyWith(title: newTitle);
179
180 final query = db.conversationBox
181 .query(ConversationEntity_.id.equals(id))
182 .build();
183 final existingEntity = query.findFirst();
184 query.close();
185
186 final entity = ConversationEntity.fromDomain(updated);
187 if (existingEntity != null) {
188 entity.internalId = existingEntity.internalId;
189 }
190 db.conversationBox.put(entity);
191
192 _updateInState(updated);
193 }
194
195 Future<void> setTemporary(String id, bool isTemporary) async {
196 final db = ref.read(databaseProvider);
197 final conversations = state.value ?? [];
198 final existing = conversations.firstWhere(
199 (c) => c.id == id,
200 orElse: () => throw Exception('Conversation not found in state'),
201 );
202
203 final updated = existing.copyWith(
204 isTemporary: isTemporary,
205 updatedAt: DateTime.now(),
206 );
207
208 final query = db.conversationBox
209 .query(ConversationEntity_.id.equals(id))
210 .build();
211 final existingEntity = query.findFirst();
212 query.close();
213
214 final entity = ConversationEntity.fromDomain(updated);
215 if (existingEntity != null) {
216 entity.internalId = existingEntity.internalId;
217 }
218 db.conversationBox.put(entity);
219
220 _updateInState(updated);
221 }
222
223 Future<void> deleteConversation(String id) async {
224 final db = ref.read(databaseProvider);
225
226 // Delete messages first
227 final msgQuery = db.messageBox
228 .query(MessageEntity_.conversationUid.equals(id))
229 .build();
230 db.messageBox.removeMany(msgQuery.findIds());
231 msgQuery.close();
232
233 // Delete conversation
234 final convQuery = db.conversationBox
235 .query(ConversationEntity_.id.equals(id))
236 .build();
237 db.conversationBox.removeMany(convQuery.findIds());
238 convQuery.close();
239
240 await _updateState();
241 }
242
243 Future<void> togglePin(String id) async {
244 final db = ref.read(databaseProvider);
245 final conversations = state.value ?? [];
246 final existing = conversations.firstWhere(
247 (c) => c.id == id,
248 orElse: () => throw Exception('Conversation not found in state'),
249 );
250
251 final updated = existing.copyWith(
252 isPinned: !existing.isPinned,
253 updatedAt: DateTime.now(),
254 );
255
256 final query = db.conversationBox
257 .query(ConversationEntity_.id.equals(id))
258 .build();
259 final existingEntity = query.findFirst();
260 query.close();
261
262 final entity = ConversationEntity.fromDomain(updated);
263 if (existingEntity != null) {
264 entity.internalId = existingEntity.internalId;
265 }
266 db.conversationBox.put(entity);
267
268 _updateInState(updated);
269 }
270
271 Future<void> setArchived(String id, bool archived) async {
272 final db = ref.read(databaseProvider);
273 final conversations = state.value ?? [];
274 final existing = conversations.firstWhere(
275 (c) => c.id == id,
276 orElse: () => throw Exception('Conversation not found in state'),
277 );
278
279 final updated = existing.copyWith(
280 isArchived: archived,
281 updatedAt: DateTime.now(),
282 );
283
284 final query = db.conversationBox
285 .query(ConversationEntity_.id.equals(id))
286 .build();
287 final existingEntity = query.findFirst();
288 query.close();
289
290 final entity = ConversationEntity.fromDomain(updated);
291 if (existingEntity != null) {
292 entity.internalId = existingEntity.internalId;
293 }
294 db.conversationBox.put(entity);
295
296 _updateInState(updated);
297 }
298
299 Future<void> updatePreview(
300 String id,
301 String preview,
302 DateTime updatedAt, {
303 int? messageCount,
304 int? characterCountDelta,
305 int? characterCount,
306 }) async {
307 final db = ref.read(databaseProvider);
308 final conversations = state.value ?? [];
309 final existingIndex = conversations.indexWhere((c) => c.id == id);
310 if (existingIndex != -1) {
311 final existing = conversations[existingIndex];
312 final updated = existing.copyWith(
313 lastMessagePreview: preview,
314 updatedAt: updatedAt,
315 messageCount: messageCount ?? existing.messageCount,
316 characterCount:
317 characterCount ??
318 (characterCountDelta != null
319 ? existing.characterCount + characterCountDelta
320 : existing.characterCount),
321 );
322
323 final query = db.conversationBox
324 .query(ConversationEntity_.id.equals(id))
325 .build();
326 final existingEntity = query.findFirst();
327 query.close();
328
329 final entity = ConversationEntity.fromDomain(updated);
330 if (existingEntity != null) {
331 entity.internalId = existingEntity.internalId;
332 }
333 db.conversationBox.put(entity);
334
335 await _updateState();
336 }
337 }
338
339 Future<void> syncConversationStats(
340 String id, {
341 required int messageCount,
342 required int characterCount,
343 String? preview,
344 }) async {
345 final db = ref.read(databaseProvider);
346 final conversations = state.value ?? [];
347 final existingIndex = conversations.indexWhere((c) => c.id == id);
348 if (existingIndex == -1) return;
349
350 final existing = conversations[existingIndex];
351 final updated = existing.copyWith(
352 messageCount: messageCount,
353 characterCount: characterCount,
354 lastMessagePreview: preview ?? existing.lastMessagePreview,
355 updatedAt: DateTime.now(),
356 );
357
358 final query = db.conversationBox
359 .query(ConversationEntity_.id.equals(id))
360 .build();
361 final existingEntity = query.findFirst();
362 query.close();
363
364 final entity = ConversationEntity.fromDomain(updated);
365 if (existingEntity != null) {
366 entity.internalId = existingEntity.internalId;
367 }
368 db.conversationBox.put(entity);
369 await _updateState();
370 }
371
372 /// Updates just the embeddings-derived total token count without
373 /// touching `updatedAt` — this runs as a background recount after every
374 /// send/edit/regenerate and shouldn't bump the conversation's sort order
375 /// or last-modified time on its own.
376 Future<void> updateTokenCount(String id, int totalTokenCount) async {
377 final db = ref.read(databaseProvider);
378 final conversations = state.value ?? [];
379 final existingIndex = conversations.indexWhere((c) => c.id == id);
380 if (existingIndex == -1) return;
381
382 final existing = conversations[existingIndex];
383 final updated = existing.copyWith(totalTokenCount: totalTokenCount);
384
385 final query = db.conversationBox
386 .query(ConversationEntity_.id.equals(id))
387 .build();
388 final existingEntity = query.findFirst();
389 query.close();
390
391 final entity = ConversationEntity.fromDomain(updated);
392 if (existingEntity != null) {
393 entity.internalId = existingEntity.internalId;
394 }
395 db.conversationBox.put(entity);
396 await _updateState();
397 }
398
399 Future<void> updatePersonas(String id, List<Persona> personas) async {
400 final personaId = personas.isEmpty
401 ? null
402 : PersonaPromptUtils.joinPersonaIds(personas.map((p) => p.id).toList());
403 final systemPrompt = personas.isEmpty
404 ? null
405 : PersonaPromptUtils.combineSystemPrompts(personas);
406 await updatePersona(id, personaId, systemPrompt);
407 }
408
409 Future<void> updatePersona(
410 String id,
411 String? personaId,
412 String? systemPrompt,
413 ) async {
414 final db = ref.read(databaseProvider);
415 final conversations = state.value ?? [];
416 final existingIndex = conversations.indexWhere((c) => c.id == id);
417 if (existingIndex != -1) {
418 final existing = conversations[existingIndex];
419 final updated = existing.copyWith(
420 personaId: personaId,
421 clearPersona: personaId == null,
422 systemPrompt: systemPrompt,
423 clearSystemPrompt: systemPrompt == null,
424 updatedAt: DateTime.now(),
425 );
426
427 final query = db.conversationBox
428 .query(ConversationEntity_.id.equals(id))
429 .build();
430 final existingEntity = query.findFirst();
431 query.close();
432
433 final entity = ConversationEntity.fromDomain(updated);
434 if (existingEntity != null) {
435 entity.internalId = existingEntity.internalId;
436 }
437 db.conversationBox.put(entity);
438
439 await _updateState();
440 }
441 }
442
443 Future<void> updateChatParams(
444 String id, {
445 double? temperature,
446 bool clearTemperature = false,
447 double? topP,
448 bool clearTopP = false,
449 int? maxTokens,
450 bool clearMaxTokens = false,
451 int? contextLength,
452 bool clearContextLength = false,
453 }) async {
454 final db = ref.read(databaseProvider);
455 final conversations = state.value ?? [];
456 final existingIndex = conversations.indexWhere((c) => c.id == id);
457 if (existingIndex != -1) {
458 final existing = conversations[existingIndex];
459 final updated = existing.copyWith(
460 temperature: temperature,
461 clearTemperature: clearTemperature,
462 topP: topP,
463 clearTopP: clearTopP,
464 maxTokens: maxTokens,
465 clearMaxTokens: clearMaxTokens,
466 contextLength: contextLength,
467 clearContextLength: clearContextLength,
468 updatedAt: DateTime.now(),
469 );
470
471 final query = db.conversationBox
472 .query(ConversationEntity_.id.equals(id))
473 .build();
474 final existingEntity = query.findFirst();
475 query.close();
476
477 final entity = ConversationEntity.fromDomain(updated);
478 if (existingEntity != null) {
479 entity.internalId = existingEntity.internalId;
480 }
481 db.conversationBox.put(entity);
482
483 await _updateState();
484 }
485 }
486
487 Future<void> updateSmartReplies(
488 String id,
489 List<String> replies,
490 String lastMessageId,
491 ) async {
492 final db = ref.read(databaseProvider);
493 final conversations = state.value ?? [];
494 final existingIndex = conversations.indexWhere((c) => c.id == id);
495 if (existingIndex != -1) {
496 final existing = conversations[existingIndex];
497 final updated = existing.copyWith(
498 smartReplies: replies,
499 smartRepliesLastMessageId: lastMessageId,
500 );
501
502 final query = db.conversationBox
503 .query(ConversationEntity_.id.equals(id))
504 .build();
505 final existingEntity = query.findFirst();
506 query.close();
507
508 final entity = ConversationEntity.fromDomain(updated);
509 if (existingEntity != null) {
510 entity.internalId = existingEntity.internalId;
511 }
512 db.conversationBox.put(entity);
513
514 await _updateState();
515 }
516 }
517
518 Future<void> deleteAll() async {
519 final db = ref.read(databaseProvider);
520 db.messageBox.removeAll();
521 db.conversationBox.removeAll();
522 await _updateState();
523 }
524
525 Future<Conversation> duplicateConversation(String id) async {
526 final db = ref.read(databaseProvider);
527 final conversations = state.value ?? [];
528 final source = conversations.firstWhere((c) => c.id == id);
529
530 final now = DateTime.now();
531 final newId = _generateUuid();
532 final duplicate = source.copyWith(
533 id: newId,
534 title: '${source.title} (copy)',
535 createdAt: now,
536 updatedAt: now,
537 isPinned: false,
538 );
539
540 db.conversationBox.put(ConversationEntity.fromDomain(duplicate));
541
542 final msgQuery = db.messageBox
543 .query(MessageEntity_.conversationUid.equals(id))
544 .build();
545 final sourceMessages = msgQuery.find();
546 msgQuery.close();
547
548 final convQuery = db.conversationBox
549 .query(ConversationEntity_.id.equals(newId))
550 .build();
551 final convEntity = convQuery.findFirst();
552 convQuery.close();
553
554 if (convEntity != null) {
555 for (final entity in sourceMessages) {
556 final message = entity.toDomain().copyWith(
557 id: _generateUuid(),
558 conversationId: newId,
559 );
560 final copyEntity = MessageEntity.fromDomain(message)
561 ..conversation.target = convEntity;
562 db.messageBox.put(copyEntity);
563 }
564 }
565
566 await _updateState();
567 return duplicate;
568 }
569
570 Future<void> moveConversationToFolder(String id, String? folderId) async {
571 final db = ref.read(databaseProvider);
572 final conversations = state.value ?? [];
573 final existing = conversations.firstWhere((c) => c.id == id);
574 // Moving folders is a metadata edit, not conversation activity — don't
575 // bump updatedAt, since that's used as the "last modified" sort/section
576 // date.
577 final updated = existing.copyWith(
578 folderId: folderId,
579 clearFolderId: folderId == null,
580 );
581
582 final query = db.conversationBox
583 .query(ConversationEntity_.id.equals(id))
584 .build();
585 final existingEntity = query.findFirst();
586 query.close();
587
588 final entity = ConversationEntity.fromDomain(updated);
589 if (existingEntity != null) entity.internalId = existingEntity.internalId;
590 db.conversationBox.put(entity);
591 await _updateState();
592 }
593
594 String _generateUuid() {
595 final secure = Random.secure();
596 final now = DateTime.now().microsecondsSinceEpoch.toRadixString(16);
597 final r1 = secure.nextInt(0xFFFFFFFF).toRadixString(16).padLeft(8, '0');
598 final r2 = secure.nextInt(0xFFFFFFFF).toRadixString(16).padLeft(8, '0');
599 return '$now-$r1-$r2';
600 }
601 }
602
603 final activeConversationIdProvider =
604 NotifierProvider<ActiveConversationIdNotifier, String?>(() {
605 return ActiveConversationIdNotifier();
606 });
607
608 class ActiveConversationIdNotifier extends Notifier<String?> {
609 @override
610 String? build() => null;
611
612 void setActiveConversationId(String? id) {
613 state = id;
614 }
615
616 void setActiveConversation(Conversation? conversation) {
617 state = conversation?.id;
618 }
619 }
620
621 final activeConversationProvider =
622 NotifierProvider<ActiveConversationNotifier, Conversation?>(() {
623 return ActiveConversationNotifier();
624 });
625
626 class ActiveConversationNotifier extends Notifier<Conversation?> {
627 @override
628 Conversation? build() {
629 final activeId = ref.watch(activeConversationIdProvider);
630 if (activeId == null) return null;
631 final conversations = ref.watch(conversationsProvider).value ?? [];
632 return conversations.where((c) => c.id == activeId).firstOrNull;
633 }
634
635 void setActiveConversation(Conversation? conversation) {
636 ref
637 .read(activeConversationIdProvider.notifier)
638 .setActiveConversation(conversation);
639 state = conversation;
640 }
641 }
642
643 final conversationSearchProvider =
644 NotifierProvider<ConversationSearchNotifier, String>(() {
645 return ConversationSearchNotifier();
646 });
647
648 class ConversationSearchNotifier extends Notifier<String> {
649 @override
650 String build() => '';
651
652 void setSearchQuery(String query) {
653 state = query;
654 }
655
656 void clearSearch() {
657 state = '';
658 }
659 }
660
661 enum HistorySortOption { modified, created }
662
663 final historySortOptionProvider =
664 NotifierProvider<HistorySortOptionNotifier, HistorySortOption>(() {
665 return HistorySortOptionNotifier();
666 });
667
668 class HistorySortOptionNotifier extends Notifier<HistorySortOption> {
669 @override
670 HistorySortOption build() => HistorySortOption.modified;
671
672 void setOption(HistorySortOption option) => state = option;
673 }
674
675 DateTime historySortDate(Conversation c, HistorySortOption option) =>
676 option == HistorySortOption.created ? c.createdAt : c.updatedAt;
677
678 final filteredConversationsProvider = Provider<AsyncValue<List<Conversation>>>((
679 ref,
680 ) {
681 final conversationsAsync = ref.watch(conversationsProvider);
682 final query = ref.watch(conversationSearchProvider).toLowerCase();
683 final folderFilter = ref.watch(historyFolderFilterProvider);
684 final listFilter = ref.watch(historyListFilterProvider);
685 final sortOption = ref.watch(historySortOptionProvider);
686
687 return conversationsAsync.whenData((conversations) {
688 var filtered = conversations.where((c) => !c.isTemporary).toList();
689
690 switch (listFilter) {
691 case HistoryListFilter.archived:
692 filtered = filtered.where((c) => c.isArchived).toList();
693 case HistoryListFilter.pinned:
694 filtered = filtered.where((c) => c.isPinned && !c.isArchived).toList();
695 case HistoryListFilter.all:
696 filtered = filtered.where((c) => !c.isArchived).toList();
697 }
698
699 if (folderFilter != null) {
700 if (folderFilter.isEmpty) {
701 filtered = filtered
702 .where((c) => c.folderId == null || c.folderId!.isEmpty)
703 .toList();
704 } else {
705 filtered = filtered.where((c) => c.folderId == folderFilter).toList();
706 }
707 }
708 if (query.isNotEmpty) {
709 filtered = filtered.where((c) {
710 return c.title.toLowerCase().contains(query) ||
711 (c.lastMessagePreview?.toLowerCase().contains(query) ?? false);
712 }).toList();
713 }
714
715 filtered.sort((a, b) {
716 if (a.isPinned != b.isPinned) return a.isPinned ? -1 : 1;
717 return historySortDate(
718 b,
719 sortOption,
720 ).compareTo(historySortDate(a, sortOption));
721 });
722 return filtered;
723 });
724 });
725
726 final recentConversationsProvider = Provider<List<Conversation>>((ref) {
727 final allAsync = ref.watch(conversationsProvider);
728 final all = allAsync.value ?? [];
729 return all.where((c) => !c.isTemporary).take(3).toList();
730 });
731
732 final groupedConversationsProvider =
733 Provider<AsyncValue<Map<String, List<Conversation>>>>((ref) {
734 final filteredAsync = ref.watch(filteredConversationsProvider);
735 final sortOption = ref.watch(historySortOptionProvider);
736
737 return filteredAsync.whenData((conversations) {
738 final now = DateTime.now();
739 final today = DateTime(now.year, now.month, now.day);
740 final yesterday = today.subtract(const Duration(days: 1));
741 final sevenDaysAgo = today.subtract(const Duration(days: 7));
742 final thirtyDaysAgo = today.subtract(const Duration(days: 30));
743
744 final grouped = <String, List<Conversation>>{};
745
746 for (final conversation in conversations) {
747 final sectionDate = historySortDate(conversation, sortOption);
748 final convDate = DateTime(
749 sectionDate.year,
750 sectionDate.month,
751 sectionDate.day,
752 );
753
754 String section;
755 if (conversation.isPinned) {
756 section = 'PINNED';
757 } else if (convDate.isAtSameMomentAs(today)) {
758 section = 'TODAY';
759 } else if (convDate.isAtSameMomentAs(yesterday)) {
760 section = 'YESTERDAY';
761 } else if (convDate.isAfter(sevenDaysAgo)) {
762 section = 'PREVIOUS 7 DAYS';
763 } else if (convDate.isAfter(thirtyDaysAgo)) {
764 section = 'PREVIOUS 30 DAYS';
765 } else {
766 section = 'OLDER';
767 }
768
769 grouped.putIfAbsent(section, () => []).add(conversation);
770 }
771
772 return grouped;
773 });
774 });
775
776 final historyFolderFilterProvider =
777 NotifierProvider<HistoryFolderFilterNotifier, String?>(() {
778 return HistoryFolderFilterNotifier();
779 });
780
781 enum HistoryListFilter { all, pinned, archived }
782
783 final historyListFilterProvider =
784 NotifierProvider<HistoryListFilterNotifier, HistoryListFilter>(() {
785 return HistoryListFilterNotifier();
786 });
787
788 class HistoryListFilterNotifier extends Notifier<HistoryListFilter> {
789 @override
790 HistoryListFilter build() => HistoryListFilter.all;
791
792 void setFilter(HistoryListFilter filter) => state = filter;
793 }
794
795 class HistoryFolderFilterNotifier extends Notifier<String?> {
796 @override
797 String? build() => null;
798
799 void setFilter(String? folderId) => state = folderId;
800 }
801
802 final historySelectionModeProvider =
803 NotifierProvider<HistorySelectionModeNotifier, bool>(
804 HistorySelectionModeNotifier.new,
805 );
806
807 class HistorySelectionModeNotifier extends Notifier<bool> {
808 @override
809 bool build() => false;
810
811 void enable() => state = true;
812
813 void disable() {
814 state = false;
815 ref.read(historySelectedIdsProvider.notifier).clear();
816 }
817 }
818
819 final historySelectedIdsProvider =
820 NotifierProvider<HistorySelectedIdsNotifier, Set<String>>(
821 HistorySelectedIdsNotifier.new,
822 );
823
824 class HistorySelectedIdsNotifier extends Notifier<Set<String>> {
825 @override
826 Set<String> build() => const {};
827
828 void toggle(String id) {
829 final updated = {...state};
830 if (!updated.remove(id)) updated.add(id);
831 state = updated;
832 }
833
834 void clear() => state = const {};
835 }
836
837 final conversationFoldersProvider =
838 AsyncNotifierProvider<
839 ConversationFoldersNotifier,
840 List<ConversationFolder>
841 >(() => ConversationFoldersNotifier());
842
843 class ConversationFoldersNotifier
844 extends AsyncNotifier<List<ConversationFolder>> {
845 @override
846 Future<List<ConversationFolder>> build() async => _loadAll();
847
848 Future<List<ConversationFolder>> _loadAll() async {
849 if (!ref.mounted) return [];
850 final db = ref.read(databaseProvider);
851 return db.store.runInTransactionAsync(
852 TxMode.read,
853 _loadFoldersInBackground,
854 null,
855 );
856 }
857
858 Future<void> _updateState() async {
859 final data = await _loadAll();
860 if (ref.mounted) {
861 state = AsyncData(data);
862 }
863 }
864
865 static List<ConversationFolder> _loadFoldersInBackground(Store store, _) {
866 final entities = store.box<ConversationFolderEntity>().getAll();
867 final folders = entities
868 .map(
869 (e) => ConversationFolder(
870 id: e.id,
871 name: e.name,
872 sortOrder: e.sortOrder,
873 createdAt: e.createdAt,
874 ),
875 )
876 .toList();
877 folders.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
878 return folders;
879 }
880
881 Future<ConversationFolder> createFolder(String name) async {
882 final db = ref.read(databaseProvider);
883 final folder = ConversationFolder(
884 id: DateTime.now().millisecondsSinceEpoch.toString(),
885 name: name,
886 sortOrder: (state.value ?? []).length,
887 createdAt: DateTime.now(),
888 );
889 db.conversationFolderBox.put(
890 ConversationFolderEntity(
891 id: folder.id,
892 name: folder.name,
893 sortOrder: folder.sortOrder,
894 createdAt: folder.createdAt,
895 ),
896 );
897 await _updateState();
898 return folder;
899 }
900
901 Future<void> renameFolder(String id, String name) async {
902 final db = ref.read(databaseProvider);
903 final query = db.conversationFolderBox
904 .query(ConversationFolderEntity_.id.equals(id))
905 .build();
906 final entity = query.findFirst();
907 query.close();
908 if (entity == null) return;
909 entity.name = name;
910 db.conversationFolderBox.put(entity);
911 await _updateState();
912 }
913
914 Future<void> deleteFolder(String id) async {
915 final db = ref.read(databaseProvider);
916 final query = db.conversationFolderBox
917 .query(ConversationFolderEntity_.id.equals(id))
918 .build();
919 db.conversationFolderBox.removeMany(query.findIds());
920 query.close();
921
922 final convQuery = db.conversationBox
923 .query(ConversationEntity_.folderId.equals(id))
924 .build();
925 for (final entity in convQuery.find()) {
926 entity.folderId = null;
927 db.conversationBox.put(entity);
928 }
929 convQuery.close();
930
931 ref.invalidate(conversationsProvider);
932 await _updateState();
933 }
934 }
935
936 final searchMessageContentsProvider =
937 NotifierProvider<SearchMessageContentsNotifier, bool>(() {
938 return SearchMessageContentsNotifier();
939 });
940
941 class SearchMessageContentsNotifier extends Notifier<bool> {
942 @override
943 bool build() => false;
944
945 void toggle() => state = !state;
946
947 void setEnabled(bool enabled) => state = enabled;
948 }
949
950 final scrollToMessageIdProvider =
951 NotifierProvider<ScrollToMessageNotifier, String?>(() {
952 return ScrollToMessageNotifier();
953 });
954
955 final focusHistorySearchProvider =
956 NotifierProvider<FocusHistorySearchNotifier, int>(() {
957 return FocusHistorySearchNotifier();
958 });
959
960 class FocusHistorySearchNotifier extends Notifier<int> {
961 @override
962 int build() => 0;
963
964 void requestFocus() => state++;
965
966 void clear() => state = 0;
967 }
968
969 class ScrollToMessageNotifier extends Notifier<String?> {
970 @override
971 String? build() => null;
972
973 void scrollTo(String messageId) => state = messageId;
974
975 void clear() => state = null;
976 }
977
978 const _messageSearchService = MessageSearchService();
979
980 final messageSearchResultsProvider = Provider<List<MessageSearchHit>>((ref) {
981 final query = ref.watch(conversationSearchProvider);
982 if (query.trim().isEmpty || !ref.watch(searchMessageContentsProvider)) {
983 return const [];
984 }
985
986 final db = ref.watch(databaseProvider);
987 return _messageSearchService.searchMessages(db, query: query);
988 });