From d7a65a5770f50a377db4e3a567135b5966a6acf6 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sun, 16 Jun 2024 13:07:16 +0200 Subject: [PATCH] refactored chat archive and added search --- .../data/database/daos/search.dart | 49 ++ .../data/database/daos/search.g.dart | 6 + .../chat_archive/data/database/database.dart | 22 + .../chat_archive/data/database/database.drift | 43 ++ .../data/database/database.g.dart | 641 ++++++++++++++++++ .../features/chat_archive/data/providers.dart | 31 + .../chat_archive/data/providers.g.dart | 27 + .../repositories/chat_archive_file.g.dart | 28 - .../file.dart} | 25 +- .../chat_archive/data/services/file.g.dart | 27 + .../{chat_archive.dart => archive.dart} | 19 +- .../{chat_archive.g.dart => archive.g.dart} | 4 +- .../domain/repositories/search.dart | 120 ++++ .../domain/repositories/search.g.dart | 28 + .../presentation/screens/detail.dart | 6 +- .../presentation/screens/list.dart | 14 +- .../presentation/screens/search.dart | 111 +++ .../presentation/widgets/web_view.dart | 2 +- 18 files changed, 1150 insertions(+), 53 deletions(-) create mode 100644 app/lib/features/chat_archive/data/database/daos/search.dart create mode 100644 app/lib/features/chat_archive/data/database/daos/search.g.dart create mode 100644 app/lib/features/chat_archive/data/database/database.dart create mode 100644 app/lib/features/chat_archive/data/database/database.drift create mode 100644 app/lib/features/chat_archive/data/database/database.g.dart create mode 100644 app/lib/features/chat_archive/data/providers.dart create mode 100644 app/lib/features/chat_archive/data/providers.g.dart delete mode 100644 app/lib/features/chat_archive/data/repositories/chat_archive_file.g.dart rename app/lib/features/chat_archive/data/{repositories/chat_archive_file.dart => services/file.dart} (71%) create mode 100644 app/lib/features/chat_archive/data/services/file.g.dart rename app/lib/features/chat_archive/domain/repositories/{chat_archive.dart => archive.dart} (73%) rename app/lib/features/chat_archive/domain/repositories/{chat_archive.g.dart => archive.g.dart} (98%) create mode 100644 app/lib/features/chat_archive/domain/repositories/search.dart create mode 100644 app/lib/features/chat_archive/domain/repositories/search.g.dart create mode 100644 app/lib/features/chat_archive/presentation/screens/search.dart diff --git a/app/lib/features/chat_archive/data/database/daos/search.dart b/app/lib/features/chat_archive/data/database/daos/search.dart new file mode 100644 index 00000000..b01b0aaa --- /dev/null +++ b/app/lib/features/chat_archive/data/database/daos/search.dart @@ -0,0 +1,49 @@ +import 'package:bang_navigator/features/chat_archive/data/database/database.dart'; +import 'package:drift/drift.dart'; + +part 'search.g.dart'; + +@DriftAccessor() +class SearchDao extends DatabaseAccessor + with _$SearchDaoMixin { + SearchDao(super.db); + + Future indexChats(Iterable chats) { + return db.chat.insertAll(chats); + } + + Future deleteAllChats() { + return db.chat.deleteAll(); + } + + Future deleteChat(String fileName) { + return (db.chat.delete()..where((t) => t.fileName.equals(fileName))).go(); + } + + Future upsertChat(ChatCompanion chat) { + return db.chat.insertOne( + chat, + onConflict: DoUpdate( + (old) => ChatCompanion.custom( + content: Variable(chat.content.value), + ), + ), + ); + } + + Selectable queryChats({ + required String matchPrefix, + required String matchSuffix, + required String ellipsis, + required int snippetLength, + required String searchString, + }) { + return db.chatQuery( + query: db.buildQuery(searchString), + snippetLength: snippetLength, + beforeMatch: matchPrefix, + afterMatch: matchSuffix, + ellipsis: ellipsis, + ); + } +} diff --git a/app/lib/features/chat_archive/data/database/daos/search.g.dart b/app/lib/features/chat_archive/data/database/daos/search.g.dart new file mode 100644 index 00000000..b689a778 --- /dev/null +++ b/app/lib/features/chat_archive/data/database/daos/search.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search.dart'; + +// ignore_for_file: type=lint +mixin _$SearchDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/chat_archive/data/database/database.dart b/app/lib/features/chat_archive/data/database/database.dart new file mode 100644 index 00000000..b4c4dd69 --- /dev/null +++ b/app/lib/features/chat_archive/data/database/database.dart @@ -0,0 +1,22 @@ +import 'package:bang_navigator/features/chat_archive/data/database/daos/search.dart'; +import 'package:bang_navigator/features/query/domain/tokenizer.dart'; +import 'package:drift/drift.dart'; + +part 'database.g.dart'; + +@DriftDatabase( + include: {'database.drift'}, + daos: [SearchDao], +) +class ChatSearchDatabase extends _$ChatSearchDatabase + with TrigramQueryBuilderMixin { + @override + final int schemaVersion = 1; + + @override + final int ftsTokenLimit = 6; + @override + final int ftsMinTokenLength = 3; + + ChatSearchDatabase(super.e); +} diff --git a/app/lib/features/chat_archive/data/database/database.drift b/app/lib/features/chat_archive/data/database/database.drift new file mode 100644 index 00000000..8e9e47e0 --- /dev/null +++ b/app/lib/features/chat_archive/data/database/database.drift @@ -0,0 +1,43 @@ +CREATE TABLE chat ( + file_name TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL +); + +CREATE VIRTUAL TABLE chat_fts + USING fts5( + title, + content, + content=chat, + tokenize="trigram" + ); + +-- Triggers to keep the FTS index up to date. +CREATE TRIGGER chat_after_insert AFTER INSERT ON chat BEGIN + INSERT INTO + chat_fts(rowid, title, content) + VALUES (new.rowid, new.title, new.content); +END; +CREATE TRIGGER chat_after_delete AFTER DELETE ON chat BEGIN + INSERT INTO + chat_fts(chat_fts, rowid, title, content) + VALUES('delete', old.rowid, old.title, old.content); +END; +CREATE TRIGGER chat_after_update AFTER UPDATE ON chat BEGIN + INSERT INTO + chat_fts(chat_fts, rowid, title, content) + VALUES('delete', old.rowid, old.title, old.content); + INSERT INTO + chat_fts(chat_fts, title, content) + VALUES (new.rowid, new.title, new.content); +END; + +chatQuery: + SELECT + c.file_name, + highlight(chat_fts, 0, :beforeMatch, :afterMatch) AS title, + snippet(chat_fts, 1, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS content_snippet + FROM chat_fts(:query) fts + INNER JOIN + chat c ON c.rowid = fts.rowid + ORDER BY RANK; \ No newline at end of file diff --git a/app/lib/features/chat_archive/data/database/database.g.dart b/app/lib/features/chat_archive/data/database/database.g.dart new file mode 100644 index 00000000..79d57326 --- /dev/null +++ b/app/lib/features/chat_archive/data/database/database.g.dart @@ -0,0 +1,641 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class Chat extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Chat(this.attachedDatabase, [this._alias]); + late final GeneratedColumn fileName = GeneratedColumn( + 'file_name', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL'); + late final GeneratedColumn title = GeneratedColumn( + 'title', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + late final GeneratedColumn content = GeneratedColumn( + 'content', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + @override + List get $columns => [fileName, title, content]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'chat'; + @override + Set get $primaryKey => {fileName}; + @override + ChatData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ChatData( + fileName: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}file_name'])!, + title: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}title'])!, + content: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}content'])!, + ); + } + + @override + Chat createAlias(String alias) { + return Chat(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ChatData extends DataClass implements Insertable { + final String fileName; + final String title; + final String content; + const ChatData( + {required this.fileName, required this.title, required this.content}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['file_name'] = Variable(fileName); + map['title'] = Variable(title); + map['content'] = Variable(content); + return map; + } + + factory ChatData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ChatData( + fileName: serializer.fromJson(json['file_name']), + title: serializer.fromJson(json['title']), + content: serializer.fromJson(json['content']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'file_name': serializer.toJson(fileName), + 'title': serializer.toJson(title), + 'content': serializer.toJson(content), + }; + } + + ChatData copyWith({String? fileName, String? title, String? content}) => + ChatData( + fileName: fileName ?? this.fileName, + title: title ?? this.title, + content: content ?? this.content, + ); + @override + String toString() { + return (StringBuffer('ChatData(') + ..write('fileName: $fileName, ') + ..write('title: $title, ') + ..write('content: $content') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(fileName, title, content); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ChatData && + other.fileName == this.fileName && + other.title == this.title && + other.content == this.content); +} + +class ChatCompanion extends UpdateCompanion { + final Value fileName; + final Value title; + final Value content; + final Value rowid; + const ChatCompanion({ + this.fileName = const Value.absent(), + this.title = const Value.absent(), + this.content = const Value.absent(), + this.rowid = const Value.absent(), + }); + ChatCompanion.insert({ + required String fileName, + required String title, + required String content, + this.rowid = const Value.absent(), + }) : fileName = Value(fileName), + title = Value(title), + content = Value(content); + static Insertable custom({ + Expression? fileName, + Expression? title, + Expression? content, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (fileName != null) 'file_name': fileName, + if (title != null) 'title': title, + if (content != null) 'content': content, + if (rowid != null) 'rowid': rowid, + }); + } + + ChatCompanion copyWith( + {Value? fileName, + Value? title, + Value? content, + Value? rowid}) { + return ChatCompanion( + fileName: fileName ?? this.fileName, + title: title ?? this.title, + content: content ?? this.content, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (fileName.present) { + map['file_name'] = Variable(fileName.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ChatCompanion(') + ..write('fileName: $fileName, ') + ..write('title: $title, ') + ..write('content: $content, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class ChatFts extends Table + with TableInfo, VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ChatFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: ''); + late final GeneratedColumn content = GeneratedColumn( + 'content', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: ''); + @override + List get $columns => [title, content]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'chat_fts'; + @override + Set get $primaryKey => const {}; + @override + ChatFt map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ChatFt( + title: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}title'])!, + content: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}content'])!, + ); + } + + @override + ChatFts createAlias(String alias) { + return ChatFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, content, content=chat, tokenize="trigram")'; +} + +class ChatFt extends DataClass implements Insertable { + final String title; + final String content; + const ChatFt({required this.title, required this.content}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['title'] = Variable(title); + map['content'] = Variable(content); + return map; + } + + factory ChatFt.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ChatFt( + title: serializer.fromJson(json['title']), + content: serializer.fromJson(json['content']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'content': serializer.toJson(content), + }; + } + + ChatFt copyWith({String? title, String? content}) => ChatFt( + title: title ?? this.title, + content: content ?? this.content, + ); + @override + String toString() { + return (StringBuffer('ChatFt(') + ..write('title: $title, ') + ..write('content: $content') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(title, content); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ChatFt && + other.title == this.title && + other.content == this.content); +} + +class ChatFtsCompanion extends UpdateCompanion { + final Value title; + final Value content; + final Value rowid; + const ChatFtsCompanion({ + this.title = const Value.absent(), + this.content = const Value.absent(), + this.rowid = const Value.absent(), + }); + ChatFtsCompanion.insert({ + required String title, + required String content, + this.rowid = const Value.absent(), + }) : title = Value(title), + content = Value(content); + static Insertable custom({ + Expression? title, + Expression? content, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (content != null) 'content': content, + if (rowid != null) 'rowid': rowid, + }); + } + + ChatFtsCompanion copyWith( + {Value? title, Value? content, Value? rowid}) { + return ChatFtsCompanion( + title: title ?? this.title, + content: content ?? this.content, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ChatFtsCompanion(') + ..write('title: $title, ') + ..write('content: $content, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$ChatSearchDatabase extends GeneratedDatabase { + _$ChatSearchDatabase(QueryExecutor e) : super(e); + _$ChatSearchDatabaseManager get managers => _$ChatSearchDatabaseManager(this); + late final Chat chat = Chat(this); + late final ChatFts chatFts = ChatFts(this); + late final Trigger chatAfterInsert = Trigger( + 'CREATE TRIGGER chat_after_insert AFTER INSERT ON chat BEGIN INSERT INTO chat_fts ("rowid", title, content) VALUES (new."rowid", new.title, new.content);END', + 'chat_after_insert'); + late final Trigger chatAfterDelete = Trigger( + 'CREATE TRIGGER chat_after_delete AFTER DELETE ON chat BEGIN INSERT INTO chat_fts (chat_fts, "rowid", title, content) VALUES (\'delete\', old."rowid", old.title, old.content);END', + 'chat_after_delete'); + late final Trigger chatAfterUpdate = Trigger( + 'CREATE TRIGGER chat_after_update AFTER UPDATE ON chat BEGIN INSERT INTO chat_fts (chat_fts, "rowid", title, content) VALUES (\'delete\', old."rowid", old.title, old.content);INSERT INTO chat_fts (chat_fts, title, content) VALUES (new."rowid", new.title, new.content);END', + 'chat_after_update'); + late final SearchDao searchDao = SearchDao(this as ChatSearchDatabase); + Selectable chatQuery( + {required String beforeMatch, + required String afterMatch, + required String ellipsis, + required int snippetLength, + required String query}) { + return customSelect( + 'SELECT c.file_name, highlight(chat_fts, 0, ?1, ?2) AS title, snippet(chat_fts, 1, ?1, ?2, ?3, ?4) AS content_snippet FROM chat_fts(?5)AS fts INNER JOIN chat AS c ON c."rowid" = fts."rowid" ORDER BY RANK', + variables: [ + Variable(beforeMatch), + Variable(afterMatch), + Variable(ellipsis), + Variable(snippetLength), + Variable(query) + ], + readsFrom: { + chat, + chatFts, + }).map((QueryRow row) => ChatQueryResult( + fileName: row.read('file_name'), + title: row.read('title'), + contentSnippet: row.read('content_snippet'), + )); + } + + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => + [chat, chatFts, chatAfterInsert, chatAfterDelete, chatAfterUpdate]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules( + [ + WritePropagation( + on: TableUpdateQuery.onTableName('chat', + limitUpdateKind: UpdateKind.insert), + result: [ + TableUpdate('chat_fts', kind: UpdateKind.insert), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('chat', + limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('chat_fts', kind: UpdateKind.insert), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('chat', + limitUpdateKind: UpdateKind.update), + result: [ + TableUpdate('chat_fts', kind: UpdateKind.insert), + ], + ), + ], + ); +} + +typedef $ChatInsertCompanionBuilder = ChatCompanion Function({ + required String fileName, + required String title, + required String content, + Value rowid, +}); +typedef $ChatUpdateCompanionBuilder = ChatCompanion Function({ + Value fileName, + Value title, + Value content, + Value rowid, +}); + +class $ChatTableManager extends RootTableManager< + _$ChatSearchDatabase, + Chat, + ChatData, + $ChatFilterComposer, + $ChatOrderingComposer, + $ChatProcessedTableManager, + $ChatInsertCompanionBuilder, + $ChatUpdateCompanionBuilder> { + $ChatTableManager(_$ChatSearchDatabase db, Chat table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: $ChatFilterComposer(ComposerState(db, table)), + orderingComposer: $ChatOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => $ChatProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value fileName = const Value.absent(), + Value title = const Value.absent(), + Value content = const Value.absent(), + Value rowid = const Value.absent(), + }) => + ChatCompanion( + fileName: fileName, + title: title, + content: content, + rowid: rowid, + ), + getInsertCompanionBuilder: ({ + required String fileName, + required String title, + required String content, + Value rowid = const Value.absent(), + }) => + ChatCompanion.insert( + fileName: fileName, + title: title, + content: content, + rowid: rowid, + ), + )); +} + +class $ChatProcessedTableManager extends ProcessedTableManager< + _$ChatSearchDatabase, + Chat, + ChatData, + $ChatFilterComposer, + $ChatOrderingComposer, + $ChatProcessedTableManager, + $ChatInsertCompanionBuilder, + $ChatUpdateCompanionBuilder> { + $ChatProcessedTableManager(super.$state); +} + +class $ChatFilterComposer extends FilterComposer<_$ChatSearchDatabase, Chat> { + $ChatFilterComposer(super.$state); + ColumnFilters get fileName => $state.composableBuilder( + column: $state.table.fileName, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get title => $state.composableBuilder( + column: $state.table.title, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get content => $state.composableBuilder( + column: $state.table.content, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $ChatOrderingComposer + extends OrderingComposer<_$ChatSearchDatabase, Chat> { + $ChatOrderingComposer(super.$state); + ColumnOrderings get fileName => $state.composableBuilder( + column: $state.table.fileName, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get title => $state.composableBuilder( + column: $state.table.title, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get content => $state.composableBuilder( + column: $state.table.content, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $ChatFtsInsertCompanionBuilder = ChatFtsCompanion Function({ + required String title, + required String content, + Value rowid, +}); +typedef $ChatFtsUpdateCompanionBuilder = ChatFtsCompanion Function({ + Value title, + Value content, + Value rowid, +}); + +class $ChatFtsTableManager extends RootTableManager< + _$ChatSearchDatabase, + ChatFts, + ChatFt, + $ChatFtsFilterComposer, + $ChatFtsOrderingComposer, + $ChatFtsProcessedTableManager, + $ChatFtsInsertCompanionBuilder, + $ChatFtsUpdateCompanionBuilder> { + $ChatFtsTableManager(_$ChatSearchDatabase db, ChatFts table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: $ChatFtsFilterComposer(ComposerState(db, table)), + orderingComposer: $ChatFtsOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => $ChatFtsProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value title = const Value.absent(), + Value content = const Value.absent(), + Value rowid = const Value.absent(), + }) => + ChatFtsCompanion( + title: title, + content: content, + rowid: rowid, + ), + getInsertCompanionBuilder: ({ + required String title, + required String content, + Value rowid = const Value.absent(), + }) => + ChatFtsCompanion.insert( + title: title, + content: content, + rowid: rowid, + ), + )); +} + +class $ChatFtsProcessedTableManager extends ProcessedTableManager< + _$ChatSearchDatabase, + ChatFts, + ChatFt, + $ChatFtsFilterComposer, + $ChatFtsOrderingComposer, + $ChatFtsProcessedTableManager, + $ChatFtsInsertCompanionBuilder, + $ChatFtsUpdateCompanionBuilder> { + $ChatFtsProcessedTableManager(super.$state); +} + +class $ChatFtsFilterComposer + extends FilterComposer<_$ChatSearchDatabase, ChatFts> { + $ChatFtsFilterComposer(super.$state); + ColumnFilters get title => $state.composableBuilder( + column: $state.table.title, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get content => $state.composableBuilder( + column: $state.table.content, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $ChatFtsOrderingComposer + extends OrderingComposer<_$ChatSearchDatabase, ChatFts> { + $ChatFtsOrderingComposer(super.$state); + ColumnOrderings get title => $state.composableBuilder( + column: $state.table.title, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get content => $state.composableBuilder( + column: $state.table.content, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +class _$ChatSearchDatabaseManager { + final _$ChatSearchDatabase _db; + _$ChatSearchDatabaseManager(this._db); + $ChatTableManager get chat => $ChatTableManager(_db, _db.chat); + $ChatFtsTableManager get chatFts => $ChatFtsTableManager(_db, _db.chatFts); +} + +class ChatQueryResult { + final String fileName; + final String title; + final String contentSnippet; + ChatQueryResult({ + required this.fileName, + required this.title, + required this.contentSnippet, + }); +} diff --git a/app/lib/features/chat_archive/data/providers.dart b/app/lib/features/chat_archive/data/providers.dart new file mode 100644 index 00000000..da350df6 --- /dev/null +++ b/app/lib/features/chat_archive/data/providers.dart @@ -0,0 +1,31 @@ +import 'package:bang_navigator/features/chat_archive/data/database/database.dart'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:path_provider/path_provider.dart' as path_provider; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sqlite3/sqlite3.dart'; +import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; +import 'package:universal_io/io.dart'; + +part 'providers.g.dart'; + +@Riverpod() +ChatSearchDatabase chatSearchDatabase(ChatSearchDatabaseRef ref) { + return ChatSearchDatabase( + LazyDatabase(() async { + // Also work around limitations on old Android versions + if (Platform.isAndroid) { + await applyWorkaroundToOpenSqlite3OnOldAndroidVersions(); + } + + // Make sqlite3 pick a more suitable location for temporary files - the + // one from the system may be inaccessible due to sandboxing. + final cachebase = (await path_provider.getTemporaryDirectory()).path; + // We can't access /tmp on Android, which sqlite3 would try by default. + // Explicitly tell it about the correct temporary directory. + sqlite3.tempDirectory = cachebase; + + return NativeDatabase.memory(); + }), + ); +} diff --git a/app/lib/features/chat_archive/data/providers.g.dart b/app/lib/features/chat_archive/data/providers.g.dart new file mode 100644 index 00000000..cb0de8a9 --- /dev/null +++ b/app/lib/features/chat_archive/data/providers.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$chatSearchDatabaseHash() => + r'd7d2ad34264201d9196b28140b9b0ce0be6b7e1c'; + +/// See also [chatSearchDatabase]. +@ProviderFor(chatSearchDatabase) +final chatSearchDatabaseProvider = + AutoDisposeProvider.internal( + chatSearchDatabase, + name: r'chatSearchDatabaseProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$chatSearchDatabaseHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef ChatSearchDatabaseRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/chat_archive/data/repositories/chat_archive_file.g.dart b/app/lib/features/chat_archive/data/repositories/chat_archive_file.g.dart deleted file mode 100644 index 089e4e4c..00000000 --- a/app/lib/features/chat_archive/data/repositories/chat_archive_file.g.dart +++ /dev/null @@ -1,28 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'chat_archive_file.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$chatArchiveFileRepositoryHash() => - r'50268506cf0c23ba1724ece6c9f44cb87be6299f'; - -/// See also [ChatArchiveFileRepository]. -@ProviderFor(ChatArchiveFileRepository) -final chatArchiveFileRepositoryProvider = AutoDisposeNotifierProvider< - ChatArchiveFileRepository, Raw>>.internal( - ChatArchiveFileRepository.new, - name: r'chatArchiveFileRepositoryProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$chatArchiveFileRepositoryHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$ChatArchiveFileRepository - = AutoDisposeNotifier>>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/chat_archive/data/repositories/chat_archive_file.dart b/app/lib/features/chat_archive/data/services/file.dart similarity index 71% rename from app/lib/features/chat_archive/data/repositories/chat_archive_file.dart rename to app/lib/features/chat_archive/data/services/file.dart index 867d391f..314688ca 100644 --- a/app/lib/features/chat_archive/data/repositories/chat_archive_file.dart +++ b/app/lib/features/chat_archive/data/services/file.dart @@ -4,13 +4,13 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:universal_io/io.dart'; import 'package:watcher/watcher.dart'; -part 'chat_archive_file.g.dart'; +part 'file.g.dart'; @Riverpod() -class ChatArchiveFileRepository extends _$ChatArchiveFileRepository { +class ChatArchiveFileService extends _$ChatArchiveFileService { final Future _archiveDirectoryFuture; - ChatArchiveFileRepository() + ChatArchiveFileService() : _archiveDirectoryFuture = path_provider.getApplicationDocumentsDirectory().then( (documentDirectory) => Directory( @@ -19,7 +19,12 @@ class ChatArchiveFileRepository extends _$ChatArchiveFileRepository { ); Future> list() { - return _archiveDirectoryFuture.then((value) => value.list().toList()); + return _archiveDirectoryFuture.then( + (value) => value + .list() + .where((file) => path.extension(file.path) == '.md') + .toList(), + ); } Future write(String fileName, String contents) async { @@ -49,13 +54,19 @@ class ChatArchiveFileRepository extends _$ChatArchiveFileRepository { } } - @override - Raw> build() async* { + Stream _directoryStream() async* { final watcher = DirectoryWatcher( await _archiveDirectoryFuture .then((archiveDirectory) => archiveDirectory.absolute.path), ); - yield* watcher.events; + yield* watcher.events.where((event) => path.extension(event.path) == '.md'); + } + + @override + Raw> build() { + //We return a Raw stream here and yield* doesnt support broadcast. + //So it is required to use asBroadcastStream here + return _directoryStream().asBroadcastStream(); } } diff --git a/app/lib/features/chat_archive/data/services/file.g.dart b/app/lib/features/chat_archive/data/services/file.g.dart new file mode 100644 index 00000000..37229728 --- /dev/null +++ b/app/lib/features/chat_archive/data/services/file.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'file.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$chatArchiveFileServiceHash() => + r'21a60ae20e01cfd4f25f7f744fb9bc0c5a465e09'; + +/// See also [ChatArchiveFileService]. +@ProviderFor(ChatArchiveFileService) +final chatArchiveFileServiceProvider = AutoDisposeNotifierProvider< + ChatArchiveFileService, Raw>>.internal( + ChatArchiveFileService.new, + name: r'chatArchiveFileServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$chatArchiveFileServiceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ChatArchiveFileService = AutoDisposeNotifier>>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/chat_archive/domain/repositories/chat_archive.dart b/app/lib/features/chat_archive/domain/repositories/archive.dart similarity index 73% rename from app/lib/features/chat_archive/domain/repositories/chat_archive.dart rename to app/lib/features/chat_archive/domain/repositories/archive.dart index aec7174d..09d5bef6 100644 --- a/app/lib/features/chat_archive/domain/repositories/chat_archive.dart +++ b/app/lib/features/chat_archive/domain/repositories/archive.dart @@ -1,4 +1,4 @@ -import 'package:bang_navigator/features/chat_archive/data/repositories/chat_archive_file.dart'; +import 'package:bang_navigator/features/chat_archive/data/services/file.dart'; import 'package:bang_navigator/features/chat_archive/domain/entities/chat_entity.dart'; import 'package:bang_navigator/features/kagi/data/services/chat.dart'; import 'package:exceptions/exceptions.dart'; @@ -6,16 +6,15 @@ import 'package:path/path.dart' as path; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:rxdart/rxdart.dart'; -part 'chat_archive.g.dart'; +part 'archive.g.dart'; @Riverpod() class ChatArchiveRepository extends _$ChatArchiveRepository { - Future> _listArchivedChats() async { + Future> listArchivedChats() async { final files = - await ref.read(chatArchiveFileRepositoryProvider.notifier).list(); + await ref.read(chatArchiveFileServiceProvider.notifier).list(); return files - .where((file) => path.extension(file.path) == '.md') .map((file) => ChatEntity.fromFileName(path.basename(file.path))) .toList(); } @@ -26,14 +25,14 @@ class ChatArchiveRepository extends _$ChatArchiveRepository { return contentsResult.flatMapAsync( (contents) => ref - .read(chatArchiveFileRepositoryProvider.notifier) + .read(chatArchiveFileServiceProvider.notifier) .write(fileName, contents), ); } Future> readChat(String fileName) async { final contentsResult = await Result.fromAsync( - () => ref.read(chatArchiveFileRepositoryProvider.notifier).read(fileName), + () => ref.read(chatArchiveFileServiceProvider.notifier).read(fileName), ); return contentsResult.fold( @@ -51,12 +50,12 @@ class ChatArchiveRepository extends _$ChatArchiveRepository { @override Stream> build() async* { - final fileRepository = ref.watch(chatArchiveFileRepositoryProvider); + final fileRepository = ref.watch(chatArchiveFileServiceProvider); yield* ConcatStream([ - _listArchivedChats().asStream(), + listArchivedChats().asStream(), fileRepository.asyncMap( - (_) => _listArchivedChats(), + (_) => listArchivedChats(), ), ]); } diff --git a/app/lib/features/chat_archive/domain/repositories/chat_archive.g.dart b/app/lib/features/chat_archive/domain/repositories/archive.g.dart similarity index 98% rename from app/lib/features/chat_archive/domain/repositories/chat_archive.g.dart rename to app/lib/features/chat_archive/domain/repositories/archive.g.dart index 7bfa3142..45d59204 100644 --- a/app/lib/features/chat_archive/domain/repositories/chat_archive.g.dart +++ b/app/lib/features/chat_archive/domain/repositories/archive.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'chat_archive.dart'; +part of 'archive.dart'; // ************************************************************************** // RiverpodGenerator @@ -156,7 +156,7 @@ class _ReadArchivedChatProviderElement } String _$chatArchiveRepositoryHash() => - r'c53b0d2704953a205c12357642237e15e8ffd3b1'; + r'1b84b6f26a7c5f6c27874904b893a979a76e1fec'; /// See also [ChatArchiveRepository]. @ProviderFor(ChatArchiveRepository) diff --git a/app/lib/features/chat_archive/domain/repositories/search.dart b/app/lib/features/chat_archive/domain/repositories/search.dart new file mode 100644 index 00000000..9d05d24e --- /dev/null +++ b/app/lib/features/chat_archive/domain/repositories/search.dart @@ -0,0 +1,120 @@ +import 'dart:async'; + +import 'package:bang_navigator/core/logger.dart'; +import 'package:bang_navigator/features/chat_archive/data/database/database.dart'; +import 'package:bang_navigator/features/chat_archive/data/providers.dart'; +import 'package:bang_navigator/features/chat_archive/data/services/file.dart'; +import 'package:bang_navigator/features/chat_archive/domain/entities/chat_entity.dart'; +import 'package:bang_navigator/features/chat_archive/domain/repositories/archive.dart'; +import 'package:bang_navigator/features/chat_archive/utils/markdown_to_text.dart'; +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as path; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:watcher/watcher.dart'; + +part 'search.g.dart'; + +@Riverpod() +class ChatArchiveSearchRepository extends _$ChatArchiveSearchRepository { + late Completer _populatedCompleter; + late StreamController> _streamController; + late ChatSearchDatabase _searchDatabase; + + Future _readChat(ChatEntity chat) async { + final contentResult = await ref + .read(chatArchiveRepositoryProvider.notifier) + .readChat(chat.fileName); + + return contentResult.fold( + (content) => ChatCompanion.insert( + fileName: chat.fileName, + title: chat.name!, + content: markdownToText(content), + ), + onFailure: (errorMessage) { + logger.e( + 'Error reading file "${chat.fileName}": ${errorMessage.message}', + error: errorMessage.details, + stackTrace: errorMessage.stackTrace, + ); + + return null; + }, + ); + } + + Future> _availableChats() async { + final availableChats = await ref + .read(chatArchiveRepositoryProvider.notifier) + .listArchivedChats() + .then( + (chats) => Future.wait( + chats.where((chat) => chat.name != null).map(_readChat), + ), + ); + + return availableChats.whereNotNull(); + } + + Future search( + String input, { + int snippetLength = 120, + String matchPrefix = '***', + String matchSuffix = '***', + String ellipsis = '…', + }) async { + if (input.isNotEmpty) { + await _populatedCompleter.future; + await _searchDatabase.searchDao + .queryChats( + searchString: input, + snippetLength: snippetLength, + matchPrefix: matchPrefix, + matchSuffix: matchSuffix, + ellipsis: ellipsis, + ) + .get() + .then(_streamController.add); + } + } + + @override + Stream> build() async* { + _populatedCompleter = Completer(); + _streamController = StreamController(); + _searchDatabase = ref.watch(chatSearchDatabaseProvider); + + ref.onDispose(() async { + await _streamController.close(); + }); + + // populate with initial chats + await _searchDatabase.searchDao.deleteAllChats(); + await _searchDatabase.searchDao.indexChats(await _availableChats()); + _populatedCompleter.complete(); + + final changeStreamSubscription = + ref.watch(chatArchiveFileServiceProvider).listen((event) async { + final chat = ChatEntity.fromFileName(path.basename(event.path)); + + switch (event.type) { + case ChangeType.ADD: + case ChangeType.MODIFY: + if (chat.name != null) { + final companion = await _readChat(chat); + if (companion != null) { + await _searchDatabase.searchDao.upsertChat(companion); + } + } + case ChangeType.REMOVE: + await _searchDatabase.searchDao.deleteChat(chat.fileName); + } + }); + + ref.onDispose(() async { + await changeStreamSubscription.cancel(); + }); + + yield* _streamController.stream; + } +} diff --git a/app/lib/features/chat_archive/domain/repositories/search.g.dart b/app/lib/features/chat_archive/domain/repositories/search.g.dart new file mode 100644 index 00000000..707fb37a --- /dev/null +++ b/app/lib/features/chat_archive/domain/repositories/search.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$chatArchiveSearchRepositoryHash() => + r'6cb31ab2c4a37561ec17a60dae3bb4ecd3893744'; + +/// See also [ChatArchiveSearchRepository]. +@ProviderFor(ChatArchiveSearchRepository) +final chatArchiveSearchRepositoryProvider = AutoDisposeStreamNotifierProvider< + ChatArchiveSearchRepository, List>.internal( + ChatArchiveSearchRepository.new, + name: r'chatArchiveSearchRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$chatArchiveSearchRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ChatArchiveSearchRepository + = AutoDisposeStreamNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/chat_archive/presentation/screens/detail.dart b/app/lib/features/chat_archive/presentation/screens/detail.dart index d1dd36ea..f78af783 100644 --- a/app/lib/features/chat_archive/presentation/screens/detail.dart +++ b/app/lib/features/chat_archive/presentation/screens/detail.dart @@ -1,7 +1,7 @@ import 'package:bang_navigator/core/routing/routes.dart'; -import 'package:bang_navigator/features/chat_archive/data/repositories/chat_archive_file.dart'; +import 'package:bang_navigator/features/chat_archive/data/services/file.dart'; import 'package:bang_navigator/features/chat_archive/domain/entities/chat_entity.dart'; -import 'package:bang_navigator/features/chat_archive/domain/repositories/chat_archive.dart'; +import 'package:bang_navigator/features/chat_archive/domain/repositories/archive.dart'; import 'package:bang_navigator/features/chat_archive/utils/markdown_to_text.dart'; import 'package:bang_navigator/features/settings/data/repositories/settings_repository.dart'; import 'package:bang_navigator/features/web_view/presentation/controllers/switch_new_tab.dart'; @@ -78,7 +78,7 @@ class ChatArchiveDetailScreen extends HookConsumerWidget { MenuItemButton( onPressed: () async { await ref - .read(chatArchiveFileRepositoryProvider.notifier) + .read(chatArchiveFileServiceProvider.notifier) .delete(fileName); if (context.mounted) { diff --git a/app/lib/features/chat_archive/presentation/screens/list.dart b/app/lib/features/chat_archive/presentation/screens/list.dart index fb6a2577..4fc844d8 100644 --- a/app/lib/features/chat_archive/presentation/screens/list.dart +++ b/app/lib/features/chat_archive/presentation/screens/list.dart @@ -1,6 +1,6 @@ import 'package:bang_navigator/core/extension/date_time.dart'; import 'package:bang_navigator/core/routing/routes.dart'; -import 'package:bang_navigator/features/chat_archive/domain/repositories/chat_archive.dart'; +import 'package:bang_navigator/features/chat_archive/domain/repositories/archive.dart'; import 'package:bang_navigator/presentation/widgets/failure_widget.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -14,7 +14,17 @@ class ChatArchiveListScreen extends HookConsumerWidget { final chatsAsync = ref.watch(chatArchiveRepositoryProvider); return Scaffold( - appBar: AppBar(title: const Text('Chat Archive')), + appBar: AppBar( + title: const Text('Chat Archive'), + actions: [ + IconButton( + onPressed: () async { + await context.push(ChatArchiveSearchRoute().location); + }, + icon: const Icon(Icons.search), + ), + ], + ), body: SafeArea( child: Skeletonizer( enabled: chatsAsync.isLoading, diff --git a/app/lib/features/chat_archive/presentation/screens/search.dart b/app/lib/features/chat_archive/presentation/screens/search.dart new file mode 100644 index 00000000..e90b9540 --- /dev/null +++ b/app/lib/features/chat_archive/presentation/screens/search.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:bang_navigator/core/extension/date_time.dart'; +import 'package:bang_navigator/core/routing/routes.dart'; +import 'package:bang_navigator/features/chat_archive/domain/entities/chat_entity.dart'; +import 'package:bang_navigator/features/chat_archive/domain/repositories/search.dart'; +import 'package:bang_navigator/features/settings/data/repositories/settings_repository.dart'; +import 'package:bang_navigator/presentation/hooks/listenable_callback.dart'; +import 'package:bang_navigator/presentation/widgets/failure_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class ChatArchiveSearchScreen extends HookConsumerWidget { + const ChatArchiveSearchScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final resultsAsync = ref.watch(chatArchiveSearchRepositoryProvider); + final incognitoEnabled = ref.watch( + settingsRepositoryProvider + .select((value) => value.valueOrNull?.incognitoMode ?? false), + ); + + final textEditingController = useTextEditingController(); + + useListenableCallback(textEditingController, () async { + unawaited( + ref + .read(chatArchiveSearchRepositoryProvider.notifier) + .search(textEditingController.text), + ); + }); + + return Scaffold( + appBar: AppBar( + title: TextField( + enableIMEPersonalizedLearning: !incognitoEnabled, + controller: textEditingController, + autofocus: true, + autocorrect: false, + decoration: const InputDecoration.collapsed(hintText: 'Search'), + ), + actions: [ + IconButton( + onPressed: () { + if (textEditingController.text.isEmpty) { + context.pop(); + } else { + textEditingController.clear(); + } + }, + icon: const Icon(Icons.clear), + ), + ], + ), + body: resultsAsync.when( + skipLoadingOnReload: true, + data: (chats) => ListView.builder( + itemCount: chats.length, + itemBuilder: (context, index) { + final chat = chats[index]; + final chatEntity = ChatEntity.fromFileName(chat.fileName); + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () async { + await context.push( + ChatArchiveDetailRoute(fileName: chat.fileName).location, + ); + }, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Markdown( + shrinkWrap: true, + padding: EdgeInsets.zero, + data: '## ${chat.title}', + ), + if (chatEntity.dateTime != null) + Text(chatEntity.dateTime!.formatWithMinutePrecision()), + const SizedBox(height: 8.0), + Markdown( + shrinkWrap: true, + padding: EdgeInsets.zero, + data: chat.contentSnippet, + ), + ], + ), + ), + ), + ); + }, + ), + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Chat Search failed', + exception: error, + ), + ), + loading: () => const SizedBox.shrink(), + ), + ); + } +} diff --git a/app/lib/features/web_view/presentation/widgets/web_view.dart b/app/lib/features/web_view/presentation/widgets/web_view.dart index db70e49f..5315a230 100644 --- a/app/lib/features/web_view/presentation/widgets/web_view.dart +++ b/app/lib/features/web_view/presentation/widgets/web_view.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:bang_navigator/core/logger.dart'; import 'package:bang_navigator/features/bangs/domain/providers.dart'; import 'package:bang_navigator/features/chat_archive/domain/entities/chat_entity.dart'; -import 'package:bang_navigator/features/chat_archive/domain/repositories/chat_archive.dart'; +import 'package:bang_navigator/features/chat_archive/domain/repositories/archive.dart'; import 'package:bang_navigator/features/search_browser/domain/entities/modes.dart'; import 'package:bang_navigator/features/search_browser/domain/entities/sheet.dart'; import 'package:bang_navigator/features/search_browser/domain/providers.dart';