fix: preserve message scrolling, export conversation folders, and invalidate stale catalog searches
fix: preserve message scrolling, export conversation folders, and invalidate stale catalog searches
abdulmominsakib committed
Jul 5, 2026 at 16:08 UTC
ab82e4122fc03066adbe7f312035f20afbdff460
5 files changed
+339
-102
lib/core/services/data_backup_service.dart
+116
-77
@@ -16,53 +16,53 @@ import '../../objectbox.g.dart';
16
17
class DataBackupService {
18
Map<String, dynamic> _messageToMap(Message message) => {
19
- 'id': message.id,
20
- 'conversationId': message.conversationId,
21
- 'role': message.role.name,
22
- 'content': message.content,
23
- 'createdAt': message.createdAt.toIso8601String(),
24
- 'status': message.status.name,
25
- 'modelId': message.modelId,
26
- 'tokenCount': message.tokenCount,
27
- 'errorMessage': message.errorMessage,
28
- 'attachmentPaths': message.attachmentPaths,
29
- 'generationTimeMs': message.generationTimeMs,
30
- 'reasoningContent': message.reasoningContent,
31
- 'toolCallId': message.toolCallId,
32
- 'isProcessing': message.isProcessing,
33
- 'toolSessionId': message.toolSessionId,
34
- 'variantGroupId': message.variantGroupId,
35
- 'variantIndex': message.variantIndex,
36
- 'threadOrder': message.threadOrder,
37
- 'isActiveVariant': message.isActiveVariant,
38
- 'parentMessageId': message.parentMessageId,
39
- };
19
+ 'id': message.id,
20
+ 'conversationId': message.conversationId,
21
+ 'role': message.role.name,
22
+ 'content': message.content,
23
+ 'createdAt': message.createdAt.toIso8601String(),
24
+ 'status': message.status.name,
25
+ 'modelId': message.modelId,
26
+ 'tokenCount': message.tokenCount,
27
+ 'errorMessage': message.errorMessage,
28
+ 'attachmentPaths': message.attachmentPaths,
29
+ 'generationTimeMs': message.generationTimeMs,
30
+ 'reasoningContent': message.reasoningContent,
31
+ 'toolCallId': message.toolCallId,
32
+ 'isProcessing': message.isProcessing,
33
+ 'toolSessionId': message.toolSessionId,
34
+ 'variantGroupId': message.variantGroupId,
35
+ 'variantIndex': message.variantIndex,
36
+ 'threadOrder': message.threadOrder,
37
+ 'isActiveVariant': message.isActiveVariant,
38
+ 'parentMessageId': message.parentMessageId,
39
+ };
40
41
Map<String, dynamic> _conversationToMap(Conversation domain) => {
42
- 'id': domain.id,
43
- 'title': domain.title,
44
- 'createdAt': domain.createdAt.toIso8601String(),
45
- 'updatedAt': domain.updatedAt.toIso8601String(),
46
- 'isPinned': domain.isPinned,
47
- 'personaId': domain.personaId,
48
- 'serverId': domain.serverId,
49
- 'modelId': domain.modelId,
50
- 'messageCount': domain.messageCount,
51
- 'lastMessagePreview': domain.lastMessagePreview,
52
- 'systemPrompt': domain.systemPrompt,
53
- 'temperature': domain.temperature,
54
- 'topP': domain.topP,
55
- 'maxTokens': domain.maxTokens,
56
- 'contextLength': domain.contextLength,
57
- 'mcpEnabled': domain.mcpEnabled,
58
- 'smartReplies': domain.smartReplies,
59
- 'smartRepliesLastMessageId': domain.smartRepliesLastMessageId,
60
- 'folderId': domain.folderId,
61
- 'isTemporary': domain.isTemporary,
62
- 'isArchived': domain.isArchived,
63
- 'characterCount': domain.characterCount,
64
- 'totalTokenCount': domain.totalTokenCount,
65
- };
42
+ 'id': domain.id,
43
+ 'title': domain.title,
44
+ 'createdAt': domain.createdAt.toIso8601String(),
45
+ 'updatedAt': domain.updatedAt.toIso8601String(),
46
+ 'isPinned': domain.isPinned,
47
+ 'personaId': domain.personaId,
48
+ 'serverId': domain.serverId,
49
+ 'modelId': domain.modelId,
50
+ 'messageCount': domain.messageCount,
51
+ 'lastMessagePreview': domain.lastMessagePreview,
52
+ 'systemPrompt': domain.systemPrompt,
53
+ 'temperature': domain.temperature,
54
+ 'topP': domain.topP,
55
+ 'maxTokens': domain.maxTokens,
56
+ 'contextLength': domain.contextLength,
57
+ 'mcpEnabled': domain.mcpEnabled,
58
+ 'smartReplies': domain.smartReplies,
59
+ 'smartRepliesLastMessageId': domain.smartRepliesLastMessageId,
60
+ 'folderId': domain.folderId,
61
+ 'isTemporary': domain.isTemporary,
62
+ 'isArchived': domain.isArchived,
63
+ 'characterCount': domain.characterCount,
64
+ 'totalTokenCount': domain.totalTokenCount,
65
+ };
66
67
List<Map<String, dynamic>> _exportSavedMessages(Store store) {
68
final box = store.box<SavedMessageEntity>();
@@ -94,6 +94,21 @@ class DataBackupService {
94
}).toList();
95
}
96
97
+ List<Map<String, dynamic>> _conversationFoldersForConversationIds(
98
+ Map<String, dynamic> all,
99
+ Set<String> conversationIds,
100
+ ) {
101
+ final folderIds = (all['conversations'] as List)
102
+ .where((c) => conversationIds.contains((c as Map)['id']))
103
+ .map((c) => (c as Map)['folderId'] as String?)
104
+ .whereType<String>()
105
+ .toSet();
106
+ return (all['conversationFolders'] as List)
107
+ .where((f) => folderIds.contains((f as Map)['id']))
108
+ .toList()
109
+ .cast<Map<String, dynamic>>();
110
+ }
111
+
112
List<Map<String, dynamic>> _exportSavedMessageFolders(Store store) {
113
final box = store.box<SavedMessageFolderEntity>();
114
return box.getAll().map((e) {
@@ -142,10 +157,14 @@ class DataBackupService {
157
final messageBox = store.box<MessageEntity>();
158
final personaBox = store.box<PersonaEntity>();
159
145
- final conversations =
146
- convBox.getAll().map((e) => _conversationToMap(e.toDomain())).toList();
147
- final messages =
148
- messageBox.getAll().map((e) => _messageToMap(e.toDomain())).toList();
160
+ final conversations = convBox
161
+ .getAll()
162
+ .map((e) => _conversationToMap(e.toDomain()))
163
+ .toList();
164
+ final messages = messageBox
165
+ .getAll()
166
+ .map((e) => _messageToMap(e.toDomain()))
167
+ .toList();
168
169
final personas = personaBox
170
.getAll()
@@ -192,6 +211,7 @@ class DataBackupService {
211
'messages': all['messages'],
212
'savedMessages': all['savedMessages'],
213
'savedMessageFolders': all['savedMessageFolders'],
214
+ 'conversationFolders': all['conversationFolders'],
215
};
216
}
217
@@ -213,6 +233,9 @@ class DataBackupService {
233
final savedMessageFolders = (all['savedMessageFolders'] as List)
234
.where((f) => folderIds.contains((f as Map)['id']))
235
.toList();
236
+ final conversationFolders = _conversationFoldersForConversationIds(all, {
237
+ conversationId,
238
+ });
239
240
return {
241
'version': 3,
@@ -222,6 +245,7 @@ class DataBackupService {
245
'messages': messages,
246
'savedMessages': savedMessages,
247
'savedMessageFolders': savedMessageFolders,
248
+ 'conversationFolders': conversationFolders,
249
};
250
}
251
@@ -237,9 +261,7 @@ class DataBackupService {
261
.where((m) => conversationIds.contains((m as Map)['conversationId']))
262
.toList();
263
final savedMessages = (all['savedMessages'] as List)
240
- .where(
241
- (m) => conversationIds.contains((m as Map)['conversationId']),
242
- )
264
+ .where((m) => conversationIds.contains((m as Map)['conversationId']))
265
.toList();
266
final folderIds = savedMessages
267
.map((m) => (m as Map)['folderId'] as String?)
@@ -248,6 +270,10 @@ class DataBackupService {
270
final savedMessageFolders = (all['savedMessageFolders'] as List)
271
.where((f) => folderIds.contains((f as Map)['id']))
272
.toList();
273
+ final conversationFolders = _conversationFoldersForConversationIds(
274
+ all,
275
+ conversationIds,
276
+ );
277
278
return {
279
'version': 3,
@@ -257,15 +283,16 @@ class DataBackupService {
283
'messages': messages,
284
'savedMessages': savedMessages,
285
'savedMessageFolders': savedMessageFolders,
286
+ 'conversationFolders': conversationFolders,
287
};
288
}
289
290
String exportConversationsForIdsAsJson(
291
Store store,
292
Set<String> conversationIds,
266
- ) =>
267
- const JsonEncoder.withIndent(' ')
268
- .convert(exportConversationsForIds(store, conversationIds));
293
+ ) => const JsonEncoder.withIndent(
294
+ ' ',
295
+ ).convert(exportConversationsForIds(store, conversationIds));
296
297
Map<String, dynamic> exportPersonas(Store store) {
298
final all = exportAll(store);
@@ -302,8 +329,9 @@ class DataBackupService {
329
const JsonEncoder.withIndent(' ').convert(exportConversations(store));
330
331
String exportConversationAsJson(Store store, String conversationId) =>
305
- const JsonEncoder.withIndent(' ')
306
- .convert(exportConversation(store, conversationId));
332
+ const JsonEncoder.withIndent(
333
+ ' ',
334
+ ).convert(exportConversation(store, conversationId));
335
336
String exportPersonasAsJson(Store store) =>
337
const JsonEncoder.withIndent(' ').convert(exportPersonas(store));
@@ -312,10 +340,9 @@ class DataBackupService {
340
String settingsJson, {
341
Store? store,
342
SharedPreferences? prefs,
315
- }) =>
316
- const JsonEncoder.withIndent(' ').convert(
317
- exportSettings(settingsJson, store: store, prefs: prefs),
318
- );
343
+ }) => const JsonEncoder.withIndent(
344
+ ' ',
345
+ ).convert(exportSettings(settingsJson, store: store, prefs: prefs));
346
347
ArchiveFile _jsonArchiveFile(String name, String json) {
348
final bytes = utf8.encode(json);
@@ -334,9 +361,7 @@ class DataBackupService {
361
exportConversationsAsJson(store),
362
),
363
)
337
- ..addFile(
338
- _jsonArchiveFile('personas.json', exportPersonasAsJson(store)),
339
- )
364
+ ..addFile(_jsonArchiveFile('personas.json', exportPersonasAsJson(store)))
365
..addFile(
366
_jsonArchiveFile(
367
'settings.json',
@@ -347,8 +372,9 @@ class DataBackupService {
372
}
373
374
String exportAllAsJson(Store store, {SharedPreferences? prefs}) {
350
- return const JsonEncoder.withIndent(' ')
351
- .convert(exportAll(store, prefs: prefs));
375
+ return const JsonEncoder.withIndent(
376
+ ' ',
377
+ ).convert(exportAll(store, prefs: prefs));
378
}
379
380
Future<void> importFromJson(Store store, String jsonString) async {
@@ -383,7 +409,10 @@ class DataBackupService {
409
final name = backupImportString(item['name']);
410
final createdAt = backupImportDateTime(item['createdAt']);
411
final updatedAt = backupImportDateTime(item['updatedAt']);
386
- if (id == null || name == null || createdAt == null || updatedAt == null) {
412
+ if (id == null ||
413
+ name == null ||
414
+ createdAt == null ||
415
+ updatedAt == null) {
416
continue;
417
}
418
final persona = Persona(
@@ -400,8 +429,9 @@ class DataBackupService {
429
? Map<String, dynamic>.from(item['preferredParams'] as Map)
430
: null,
431
);
403
- final query =
404
- personaBox.query(PersonaEntity_.id.equals(persona.id)).build();
432
+ final query = personaBox
433
+ .query(PersonaEntity_.id.equals(persona.id))
434
+ .build();
435
final existing = query.findFirst();
436
query.close();
437
final entity = PersonaEntity.fromDomain(persona);
@@ -427,7 +457,9 @@ class DataBackupService {
457
final server = Server(
458
id: id,
459
name: name,
430
- type: ServerType.values.byName(item['type'] as String? ?? 'lmStudio'),
460
+ type: ServerType.values.byName(
461
+ item['type'] as String? ?? 'lmStudio',
462
+ ),
463
host: item['host'] as String? ?? 'localhost',
464
port: item['port'] as int? ?? 1234,
465
apiKey: item['apiKey'] as String?,
@@ -440,7 +472,9 @@ class DataBackupService {
472
iconName: item['iconName'] as String?,
473
pathPrefix: item['pathPrefix'] as String?,
474
);
443
- final query = serverBox.query(ServerEntity_.id.equals(server.id)).build();
475
+ final query = serverBox
476
+ .query(ServerEntity_.id.equals(server.id))
477
+ .build();
478
final existing = query.findFirst();
479
query.close();
480
final entity = ServerEntity.fromDomain(server);
@@ -499,8 +533,9 @@ class DataBackupService {
533
isArchived: item['isArchived'] as bool? ?? false,
534
savedAt: savedAt,
535
);
502
- final query =
503
- savedBox.query(SavedMessageEntity_.id.equals(entity.id)).build();
536
+ final query = savedBox
537
+ .query(SavedMessageEntity_.id.equals(entity.id))
538
+ .build();
539
final existing = query.findFirst();
540
query.close();
541
if (existing != null) entity.internalId = existing.internalId;
@@ -568,8 +603,9 @@ class DataBackupService {
603
characterCount: item['characterCount'] as int? ?? 0,
604
totalTokenCount: item['totalTokenCount'] as int?,
605
);
571
- final query =
572
- convBox.query(ConversationEntity_.id.equals(conversation.id)).build();
606
+ final query = convBox
607
+ .query(ConversationEntity_.id.equals(conversation.id))
608
+ .build();
609
final existing = query.findFirst();
610
query.close();
611
final entity = ConversationEntity.fromDomain(conversation);
@@ -625,7 +661,9 @@ class DataBackupService {
661
662
final entity = MessageEntity.fromDomain(message)
663
..conversation.target = convEntity;
628
- final query = messageBox.query(MessageEntity_.id.equals(message.id)).build();
664
+ final query = messageBox
665
+ .query(MessageEntity_.id.equals(message.id))
666
+ .build();
667
final existing = query.findFirst();
668
query.close();
669
if (existing != null) entity.internalId = existing.internalId;
@@ -641,7 +679,8 @@ class DataBackupService {
679
for (final entry in raw) {
680
if (entry is! String || entry.isEmpty) continue;
681
final normalized = p.normalize(entry);
644
- if (normalized == attachmentsDir || p.isWithin(attachmentsDir, normalized)) {
682
+ if (normalized == attachmentsDir ||
683
+ p.isWithin(attachmentsDir, normalized)) {
684
sanitized.add(entry);
685
}
686
}
lib/features/chat/views/components/message_list/message_list.dart
+9
@@ -123,6 +123,7 @@ class _MessageListConsumer extends ConsumerStatefulWidget {
123
124
class _MessageListConsumerState extends ConsumerState<_MessageListConsumer> {
125
final _messageKeys = <String, GlobalKey>{};
126
+ bool _checkedInitialScrollTarget = false;
127
128
@override
129
void didUpdateWidget(covariant _MessageListConsumer oldWidget) {
@@ -156,6 +157,14 @@ class _MessageListConsumerState extends ConsumerState<_MessageListConsumer> {
157
}
158
});
159
160
+ if (!_checkedInitialScrollTarget) {
161
+ _checkedInitialScrollTarget = true;
162
+ final pendingScroll = ref.read(scrollToMessageIdProvider);
163
+ if (pendingScroll != null) {
164
+ _scrollToMessage(pendingScroll);
165
+ }
166
+ }
167
+
168
final streamingMessage = ref.watch(
169
chatProvider.select((s) => s.streamingMessage),
170
);
lib/features/lm_studio_catalog/providers/lm_studio_catalog_providers.dart
+27
-25
@@ -16,15 +16,17 @@ final lmStudioCatalogServiceProvider = Provider<LmStudioCatalogService>((ref) {
16
return LmStudioCatalogService(ref.read(dioProvider));
17
});
18
19
-final lmStudioDownloadServiceProvider = Provider<LmStudioDownloadService>((ref) {
19
+final lmStudioDownloadServiceProvider = Provider<LmStudioDownloadService>((
20
+ ref,
21
+) {
22
return LmStudioDownloadService(ref.read(dioProvider));
23
});
24
25
final lmStudioStaffPicksProvider =
26
FutureProvider.autoDispose<List<LmCatalogModel>>((ref) async {
25
- final service = ref.read(lmStudioCatalogServiceProvider);
26
- return service.fetchStaffPicks();
27
-});
27
+ final service = ref.read(lmStudioCatalogServiceProvider);
28
+ return service.fetchStaffPicks();
29
+ });
30
31
class LmCatalogSearchState {
32
const LmCatalogSearchState({
@@ -82,26 +84,28 @@ class LmCatalogSearchNotifier extends Notifier<LmCatalogSearchState> {
84
return;
85
}
86
_query = trimmed;
87
+ final generation = ++_searchGeneration;
88
89
if (trimmed.isEmpty) {
90
state = const LmCatalogSearchState();
91
return;
92
}
93
91
- final generation = ++_searchGeneration;
94
state = state.copyWith(isLoading: true, clearError: true);
95
final service = ref.read(lmStudioCatalogServiceProvider);
96
97
try {
98
final staffPicks = await ref.read(lmStudioStaffPicksProvider.future);
99
if (generation != _searchGeneration) return;
98
- final staffMatches =
99
- staffPicks.where((m) => m.matchesQuery(trimmed)).toList();
100
+ final staffMatches = staffPicks
101
+ .where((m) => m.matchesQuery(trimmed))
102
+ .toList();
103
final page = await service.searchHuggingFace(query: trimmed);
104
if (generation != _searchGeneration) return;
105
final staffIds = staffMatches.map((m) => m.id).toSet();
103
- final community =
104
- page.models.where((m) => !staffIds.contains(m.id)).toList();
106
+ final community = page.models
107
+ .where((m) => !staffIds.contains(m.id))
108
+ .toList();
109
110
state = LmCatalogSearchState(
111
staffMatches: staffMatches,
@@ -125,8 +129,9 @@ class LmCatalogSearchNotifier extends Notifier<LmCatalogSearchState> {
129
try {
130
final page = await service.searchHuggingFace(nextUrl: state.nextUrl);
131
final existingIds = state.allModels.map((m) => m.id).toSet();
128
- final more =
129
- page.models.where((m) => !existingIds.contains(m.id)).toList();
132
+ final more = page.models
133
+ .where((m) => !existingIds.contains(m.id))
134
+ .toList();
135
136
state = state.copyWith(
137
communityModels: [...state.communityModels, ...more],
@@ -142,20 +147,17 @@ class LmCatalogSearchNotifier extends Notifier<LmCatalogSearchState> {
147
148
final lmCatalogSearchProvider =
149
NotifierProvider<LmCatalogSearchNotifier, LmCatalogSearchState>(
145
- LmCatalogSearchNotifier.new,
146
-);
147
-
148
-final lmModelDetailProvider =
149
- FutureProvider.autoDispose.family<LmModelDetail, LmCatalogModel>(
150
- (ref, model) async {
151
- final service = ref.read(lmStudioCatalogServiceProvider);
152
- return service.fetchModelDetail(model);
153
-});
150
+ LmCatalogSearchNotifier.new,
151
+ );
152
+
153
+final lmModelDetailProvider = FutureProvider.autoDispose
154
+ .family<LmModelDetail, LmCatalogModel>((ref, model) async {
155
+ final service = ref.read(lmStudioCatalogServiceProvider);
156
+ return service.fetchModelDetail(model);
157
+ });
158
159
class LmDownloadManagerState {
156
- const LmDownloadManagerState({
157
- this.jobs = const [],
158
- });
160
+ const LmDownloadManagerState({this.jobs = const []});
161
162
final List<LmDownloadJob> jobs;
163
@@ -445,8 +447,8 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
447
448
final lmDownloadManagerProvider =
449
NotifierProvider<LmDownloadManagerNotifier, LmDownloadManagerState>(
448
- LmDownloadManagerNotifier.new,
449
-);
450
+ LmDownloadManagerNotifier.new,
451
+ );
452
453
final lmActiveDownloadCountProvider = Provider<int>((ref) {
454
return ref.watch(lmDownloadManagerProvider).activeJobs.length;
test/core/services/data_backup_service_test.dart
new
+107
@@ -0,0 +1,107 @@
1
+import 'dart:convert';
2
+
3
+import 'package:flutter_test/flutter_test.dart';
4
+import 'package:localmind/core/services/data_backup_service.dart';
5
+import 'package:localmind/core/storage/entities.dart';
6
+import 'package:localmind/features/conversations/data/models/conversation.dart';
7
+import 'package:localmind/objectbox.g.dart';
8
+
9
+void main() {
10
+ test('conversation exports include the referenced folder metadata', () {
11
+ final folder = ConversationFolderEntity(
12
+ id: 'folder-1',
13
+ name: 'Inbox',
14
+ sortOrder: 3,
15
+ createdAt: DateTime.utc(2026, 7, 5),
16
+ );
17
+ final otherFolder = ConversationFolderEntity(
18
+ id: 'folder-2',
19
+ name: 'Archive',
20
+ sortOrder: 9,
21
+ createdAt: DateTime.utc(2026, 7, 4),
22
+ );
23
+ final conversation = ConversationEntity.fromDomain(
24
+ Conversation(
25
+ id: 'conv-1',
26
+ title: 'Exported Chat',
27
+ createdAt: DateTime.utc(2026, 7, 5),
28
+ updatedAt: DateTime.utc(2026, 7, 5),
29
+ folderId: folder.id,
30
+ ),
31
+ );
32
+ final otherConversation = ConversationEntity.fromDomain(
33
+ Conversation(
34
+ id: 'conv-2',
35
+ title: 'Different Chat',
36
+ createdAt: DateTime.utc(2026, 7, 5),
37
+ updatedAt: DateTime.utc(2026, 7, 5),
38
+ folderId: otherFolder.id,
39
+ ),
40
+ );
41
+
42
+ final store = _FakeStore({
43
+ ConversationEntity: _FakeBox([conversation, otherConversation]),
44
+ MessageEntity: _FakeBox<MessageEntity>(const []),
45
+ PersonaEntity: _FakeBox<PersonaEntity>(const []),
46
+ SavedMessageEntity: _FakeBox<SavedMessageEntity>(const []),
47
+ SavedMessageFolderEntity: _FakeBox<SavedMessageFolderEntity>(const []),
48
+ ServerEntity: _FakeBox<ServerEntity>(const []),
49
+ ConversationFolderEntity: _FakeBox([folder, otherFolder]),
50
+ });
51
+
52
+ final service = DataBackupService();
53
+
54
+ final allExport =
55
+ jsonDecode(service.exportConversationsAsJson(store))
56
+ as Map<String, dynamic>;
57
+ expect(
58
+ (allExport['conversationFolders'] as List)
59
+ .cast<Map<String, dynamic>>()
60
+ .map((folder) => folder['id'])
61
+ .toSet(),
62
+ {folder.id, otherFolder.id},
63
+ );
64
+
65
+ final singleExport =
66
+ jsonDecode(service.exportConversationAsJson(store, conversation.id))
67
+ as Map<String, dynamic>;
68
+ expect(
69
+ (singleExport['conversationFolders'] as List)
70
+ .cast<Map<String, dynamic>>()
71
+ .single['id'],
72
+ folder.id,
73
+ );
74
+
75
+ final filteredExport =
76
+ jsonDecode(
77
+ service.exportConversationsForIdsAsJson(store, {
78
+ otherConversation.id,
79
+ }),
80
+ )
81
+ as Map<String, dynamic>;
82
+ expect(
83
+ (filteredExport['conversationFolders'] as List)
84
+ .cast<Map<String, dynamic>>()
85
+ .single['id'],
86
+ otherFolder.id,
87
+ );
88
+ });
89
+}
90
+
91
+class _FakeStore extends Fake implements Store {
92
+ _FakeStore(this._boxes);
93
+
94
+ final Map<Type, Object> _boxes;
95
+
96
+ @override
97
+ Box<T> box<T>() => _boxes[T] as Box<T>;
98
+}
99
+
100
+class _FakeBox<T> extends Fake implements Box<T> {
101
+ _FakeBox(this._items);
102
+
103
+ final List<T> _items;
104
+
105
+ @override
106
+ List<T> getAll() => List<T>.from(_items);
107
+}
test/features/lm_studio_catalog/lm_catalog_search_notifier_test.dart
new
+80
@@ -0,0 +1,80 @@
1
+import 'dart:async';
2
+
3
+import 'package:dio/dio.dart';
4
+import 'package:flutter_riverpod/flutter_riverpod.dart';
5
+import 'package:flutter_test/flutter_test.dart';
6
+import 'package:localmind/features/lm_studio_catalog/data/catalog_models.dart';
7
+import 'package:localmind/features/lm_studio_catalog/data/lm_studio_catalog_service.dart';
8
+import 'package:localmind/features/lm_studio_catalog/providers/lm_studio_catalog_providers.dart';
9
+
10
+void main() {
11
+ test('clearing the query invalidates an in-flight search', () async {
12
+ final searchCompleter = Completer<HfSearchPage>();
13
+ final service = _FakeLmStudioCatalogService(
14
+ staffPicks: [
15
+ const LmCatalogModel(
16
+ id: 'lmstudio/foo',
17
+ owner: 'lmstudio',
18
+ name: 'foo',
19
+ description: 'staff pick',
20
+ isStaffPick: true,
21
+ isVerified: true,
22
+ ),
23
+ ],
24
+ searchCompleter: searchCompleter,
25
+ );
26
+
27
+ final container = ProviderContainer(
28
+ overrides: [
29
+ lmStudioCatalogServiceProvider.overrideWithValue(service),
30
+ lmStudioStaffPicksProvider.overrideWith(
31
+ (ref) async => service.staffPicks,
32
+ ),
33
+ ],
34
+ );
35
+ addTearDown(container.dispose);
36
+
37
+ final notifier = container.read(lmCatalogSearchProvider.notifier);
38
+ final searchFuture = notifier.search('foo');
39
+
40
+ await Future<void>.delayed(Duration.zero);
41
+ await notifier.search('');
42
+
43
+ expect(container.read(lmCatalogSearchProvider).allModels, isEmpty);
44
+
45
+ searchCompleter.complete(
46
+ const HfSearchPage(
47
+ models: [
48
+ LmCatalogModel(id: 'community/bar', owner: 'community', name: 'bar'),
49
+ ],
50
+ ),
51
+ );
52
+
53
+ await searchFuture;
54
+ await Future<void>.delayed(Duration.zero);
55
+
56
+ expect(container.read(lmCatalogSearchProvider).allModels, isEmpty);
57
+ });
58
+}
59
+
60
+class _FakeLmStudioCatalogService extends LmStudioCatalogService {
61
+ _FakeLmStudioCatalogService({
62
+ required this.staffPicks,
63
+ required this.searchCompleter,
64
+ }) : super(Dio());
65
+
66
+ final List<LmCatalogModel> staffPicks;
67
+ final Completer<HfSearchPage> searchCompleter;
68
+
69
+ @override
70
+ Future<List<LmCatalogModel>> fetchStaffPicks() async => staffPicks;
71
+
72
+ @override
73
+ Future<HfSearchPage> searchHuggingFace({
74
+ String query = '',
75
+ int limit = 50,
76
+ String? nextUrl,
77
+ }) {
78
+ return searchCompleter.future;
79
+ }
80
+}