From 4a2191b01d6236c18e4f5161ab2bea4f46b7b721 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Tue, 7 Jan 2025 00:06:31 +0100 Subject: [PATCH] intermediate --- app/android/app/build.gradle | 17 + app/lib/data/models/equatable_iterable.dart | 2 +- app/lib/data/models/web_page_info.dart | 7 +- app/lib/domain/services/generic_website.dart | 140 ++-- .../domain/services/generic_website.g.dart | 2 +- app/lib/extensions/database_table_size.dart | 2 +- .../bangs/data/database/daos/bang.dart | 21 + .../bangs/data/database/database.dart | 1 + .../bangs/data/database/database.drift | 38 +- .../bangs/data/database/database.g.dart | 408 +++++------ .../data/models/search_history_entry.dart | 23 + .../bangs/domain/providers/bangs.dart | 13 +- .../bangs/domain/providers/bangs.g.dart | 39 +- .../bangs/domain/providers/search.dart | 24 + .../bangs/domain/providers/search.g.dart | 177 +++++ .../bangs/domain/repositories/data.dart | 45 +- .../bangs/domain/repositories/data.g.dart | 2 +- .../bangs/presentation/screens/search.dart | 7 +- .../presentation/widgets/bang_details.dart | 25 +- .../presentation/widgets/search_field.dart | 50 +- .../presentation/widgets/site_search.dart | 12 +- .../presentation/screens/detail.dart | 24 +- .../presentation/screens/search.dart | 7 +- .../geckoview/domain/entities/tab_state.dart | 12 +- .../geckoview/domain/providers/tab_state.dart | 6 +- .../domain/providers/tab_state.g.dart | 2 +- .../features/browser/domain/providers.dart | 33 + .../features/browser/domain/providers.g.dart | 134 ++++ .../presentation/dialogs/web_page_dialog.dart | 71 +- .../browser/presentation/screens/browser.dart | 181 ++--- .../presentation/widgets/browser_view.dart | 6 + .../presentation/widgets/landing/action.dart | 14 +- .../widgets/sheets/create_tab.dart | 48 +- .../widgets/sheets/view_tabs.dart | 498 +++++++------ .../presentation/widgets/tab_preview.dart | 8 +- .../features/tabs/data/database/daos/tab.dart | 2 - .../tabs/data/database/database.drift | 1 + .../tabs/data/database/database.g.dart | 6 +- .../tabs/data/models/tab_query_result.dart | 3 + .../tabs/domain/repositories/tab_search.dart | 6 +- .../domain/repositories/tab_search.g.dart | 6 +- app/lib/features/kagi/data/services/chat.dart | 26 +- .../features/kagi/data/services/chat.g.dart | 2 +- .../kagi/presentation/tabs/assistant_tab.dart | 7 +- .../kagi/presentation/tabs/search_tab.dart | 12 +- .../kagi/presentation/tabs/summarize_tab.dart | 5 +- .../features/search/domain/fts_tokenizer.dart | 7 +- .../search/presentation/screens/search.dart | 113 ++- .../presentation/widgets/bang_chips.dart | 88 +-- .../presentation/widgets/search_field.dart | 5 +- .../repositories/settings_repository.dart | 151 ---- .../controllers/save_settings.dart | 2 +- .../presentation/screens/settings.dart | 199 +----- .../settings/utils/preference_parser.dart | 18 - .../utils/session_link_extractor.dart | 7 - .../user/data/database/daos/cache.dart | 42 ++ .../user/data/database/daos/cache.g.dart | 6 + .../user/data/database/daos/setting.dart | 27 + .../user/data/database/daos/setting.g.dart | 6 + .../features/user/data/database/database.dart | 25 + .../user/data/database/database.drift | 19 + .../user/data/database/database.g.dart | 653 ++++++++++++++++++ .../data/models/settings.dart | 33 +- .../data/models/settings.g.dart | 86 +-- app/lib/features/user/data/providers.dart | 38 + app/lib/features/user/data/providers.g.dart | 26 + .../features/user/domain/providers/cache.dart | 11 + .../user/domain/providers/cache.g.dart | 29 + .../user/domain/repositories/cache.dart | 42 ++ .../user/domain/repositories/cache.g.dart | 26 + .../user/domain/repositories/settings.dart | 57 ++ .../domain/repositories/settings.g.dart} | 8 +- .../presentation/widgets/web_view.dart | 6 +- app/lib/main.dart | 6 +- .../controllers/website_title.dart | 2 +- .../controllers/website_title.g.dart | 2 +- .../widgets/website_title_tile.dart | 4 +- .../api/GeckoTabsApiImpl.kt | 24 +- .../middleware/FlutterEventMiddleware.kt | 11 + .../pigeons/Gecko.g.kt | 19 +- .../lib/src/domain/services/gecko_event.dart | 20 +- .../lib/src/domain/services/gecko_icon.dart | 9 +- .../lib/src/pigeons/gecko.g.dart | 35 +- .../pigeons/gecko.dart | 1 + 84 files changed, 2655 insertions(+), 1383 deletions(-) create mode 100644 app/lib/features/bangs/data/models/search_history_entry.dart create mode 100644 app/lib/features/bangs/domain/providers/search.dart create mode 100644 app/lib/features/bangs/domain/providers/search.g.dart delete mode 100644 app/lib/features/settings/data/repositories/settings_repository.dart delete mode 100644 app/lib/features/settings/utils/preference_parser.dart delete mode 100644 app/lib/features/settings/utils/session_link_extractor.dart create mode 100644 app/lib/features/user/data/database/daos/cache.dart create mode 100644 app/lib/features/user/data/database/daos/cache.g.dart create mode 100644 app/lib/features/user/data/database/daos/setting.dart create mode 100644 app/lib/features/user/data/database/daos/setting.g.dart create mode 100644 app/lib/features/user/data/database/database.dart create mode 100644 app/lib/features/user/data/database/database.drift create mode 100644 app/lib/features/user/data/database/database.g.dart rename app/lib/features/{settings => user}/data/models/settings.dart (56%) rename app/lib/features/{settings => user}/data/models/settings.g.dart (62%) create mode 100644 app/lib/features/user/data/providers.dart create mode 100644 app/lib/features/user/data/providers.g.dart create mode 100644 app/lib/features/user/domain/providers/cache.dart create mode 100644 app/lib/features/user/domain/providers/cache.g.dart create mode 100644 app/lib/features/user/domain/repositories/cache.dart create mode 100644 app/lib/features/user/domain/repositories/cache.g.dart create mode 100644 app/lib/features/user/domain/repositories/settings.dart rename app/lib/features/{settings/data/repositories/settings_repository.g.dart => user/domain/repositories/settings.g.dart} (79%) diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index 07f178a1..8d04df6c 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -64,6 +64,23 @@ android { } buildTypes { + debug { + splits { + abi { + enable true + reset() + include 'arm64-v8a' //for now build only one target + universalApk false + } + } + + applicationVariants.all { variant -> + variant.outputs.all { + //rename build to make it detect by flutter + outputFileName = "../../flutter-apk/app-${variant.name}.apk" + } + } + } release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. diff --git a/app/lib/data/models/equatable_iterable.dart b/app/lib/data/models/equatable_iterable.dart index 7d7bb302..44be13e8 100644 --- a/app/lib/data/models/equatable_iterable.dart +++ b/app/lib/data/models/equatable_iterable.dart @@ -6,7 +6,7 @@ class EquatableCollection with FastEquatable { EquatableCollection(this.collection, {required this.immutable}) : assert( - collection is Map || collection is Iterable, + collection is Map || collection is Iterable || collection == null, 'Collection type not supported', ); diff --git a/app/lib/data/models/web_page_info.dart b/app/lib/data/models/web_page_info.dart index 100047aa..771cfa3c 100644 --- a/app/lib/data/models/web_page_info.dart +++ b/app/lib/data/models/web_page_info.dart @@ -9,9 +9,12 @@ class WebPageInfo { final String? title; final BrowserIcon? favicon; + bool get isPageInfoComplete => + (title?.isNotEmpty ?? false) && favicon != null; + WebPageInfo({ required this.url, - required this.title, - required this.favicon, + this.title, + this.favicon, }); } diff --git a/app/lib/domain/services/generic_website.dart b/app/lib/domain/services/generic_website.dart index 5b0d9e0a..13c23c12 100644 --- a/app/lib/domain/services/generic_website.dart +++ b/app/lib/domain/services/generic_website.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui'; import 'package:exceptions/exceptions.dart'; @@ -10,6 +11,7 @@ import 'package:http/http.dart' as http; import 'package:lensai/core/http_error_handler.dart'; import 'package:lensai/data/models/web_page_info.dart'; import 'package:lensai/features/geckoview/domain/entities/browser_icon.dart'; +import 'package:lensai/features/user/domain/repositories/cache.dart'; import 'package:lensai/utils/lru_cache.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -56,15 +58,19 @@ class GenericWebsiteService extends _$GenericWebsiteService { late http.Client _client; - final LRUCache _iconCache; + //Global icon cache + late CacheRepository _cacheRepository; + //Local decoded icon cache + final LRUCache _browserIconCache; GenericWebsiteService() : _iconsService = GeckoIconService(), - _iconCache = LRUCache(50); + _browserIconCache = LRUCache(50); @override void build() { _client = http.Client(); + _cacheRepository = ref.watch(cacheRepositoryProvider.notifier); } static Map _serializeResource(Resource resource) { @@ -89,7 +95,14 @@ class GenericWebsiteService extends _$GenericWebsiteService { ); } - static List _extractIcons(Document document) { + static Uri _resolveRelativeUri(Uri baseUri, Uri uri) { + if (!uri.isAbsolute) { + return baseUri.resolveUri(uri); + } + return uri; + } + + static List _extractIcons(Uri baseUrl, Document document) { final List icons = []; void collectLinkIcons(String rel) { @@ -99,15 +112,17 @@ class GenericWebsiteService extends _$GenericWebsiteService { final type = _typeMap[rel]; final mimeType = link.attributes['type']; if (href != null && type != null) { - icons.add( - Resource( - url: href, - type: type, - sizes: sizesToList(link.attributes['sizes']).toList(), - mimeType: (mimeType?.isNotEmpty ?? true) ? null : mimeType, - maskable: false, - ), - ); + if (Uri.tryParse(href) case final Uri url) { + icons.add( + Resource( + url: _resolveRelativeUri(baseUrl, url).toString(), + type: type, + sizes: sizesToList(link.attributes['sizes']).toList(), + mimeType: (mimeType?.isNotEmpty ?? true) ? null : mimeType, + maskable: false, + ), + ); + } } } } @@ -118,14 +133,16 @@ class GenericWebsiteService extends _$GenericWebsiteService { final content = meta.attributes['content']; final type = _typeMap[property]; if (content != null && type != null) { - icons.add( - Resource( - type: type, - url: content, - sizes: [], - maskable: false, - ), - ); + if (Uri.tryParse(content) case final Uri url) { + icons.add( + Resource( + type: type, + url: _resolveRelativeUri(baseUrl, url).toString(), + sizes: [], + maskable: false, + ), + ); + } } } } @@ -136,14 +153,16 @@ class GenericWebsiteService extends _$GenericWebsiteService { final content = meta.attributes['content']; final type = _typeMap[name]; if (content != null && type != null) { - icons.add( - Resource( - type: type, - url: content, - sizes: [], - maskable: false, - ), - ); + if (Uri.tryParse(content) case final Uri url) { + icons.add( + Resource( + type: type, + url: _resolveRelativeUri(baseUrl, url).toString(), + sizes: [], + maskable: false, + ), + ); + } } } } @@ -166,7 +185,7 @@ class GenericWebsiteService extends _$GenericWebsiteService { return icons; } - Future> getInfo(Uri url) async { + Future> fetchPageInfo(Uri url) async { return Result.fromAsync( () async { final response = @@ -175,23 +194,28 @@ class GenericWebsiteService extends _$GenericWebsiteService { final result = await compute( (args) async { final document = html_parser.parse(args[0]); + final baseUri = Uri.parse(args[1]); final title = document.querySelector('title')?.text; - final resources = _extractIcons(document); + final resources = _extractIcons(baseUri, document); return { 'title': title, 'resources': resources.map(_serializeResource).toList(), }; }, - [response.body], + [response.body, url.toString()], ); final resources = (result['resources']! as List>) .map(_deserializeResource) .toList(); - final favicon = await getUrlFavicon(url: url, resources: resources); + final favicon = await getCachedIcon(url) ?? + await loadIcon( + url: url, + resources: resources, + ); return WebPageInfo( url: url, @@ -203,26 +227,50 @@ class GenericWebsiteService extends _$GenericWebsiteService { ); } - Future getUrlFavicon({ - required Uri url, - List resources = const [], - }) async { - final cached = _iconCache.get(url.host); - if (cached == null) { - final result = - await _iconsService.loadIcon(url: url, resources: resources); + Future getCachedIcon(Uri url) async { + final cachedBrowserIcon = _browserIconCache.get(url.origin); + if (cachedBrowserIcon != null) { + return cachedBrowserIcon; + } - return _iconCache.set( - url.host, + final cachedIcon = await _cacheRepository.getCachedIcon(url.origin); + if (cachedIcon != null) { + return _browserIconCache.set( + url.origin, await BrowserIcon.fromBytes( - result.image, - dominantColor: (result.color != null) ? Color(result.color!) : null, - source: result.source, + cachedIcon, + dominantColor: null, + source: IconSource.disk, ), ); } - return cached; + return null; + } + + Future loadIcon({ + required Uri url, + required List resources, + bool isPrivate = false, + bool waitOnNetworkLoad = true, + }) async { + final result = await _iconsService.loadIcon( + url: url, + resources: resources, + isPrivate: isPrivate, + waitOnNetworkLoad: waitOnNetworkLoad, + ); + + // unawaited(_cacheRepository.cacheIcon(url, result.image)); + + return _browserIconCache.set( + url.origin, + await BrowserIcon.fromBytes( + result.image, + dominantColor: (result.color != null) ? Color(result.color!) : null, + source: result.source, + ), + ); } // Future tryUpgradeToHttps(Uri httpUri) async { diff --git a/app/lib/domain/services/generic_website.g.dart b/app/lib/domain/services/generic_website.g.dart index edcdd298..abb68817 100644 --- a/app/lib/domain/services/generic_website.g.dart +++ b/app/lib/domain/services/generic_website.g.dart @@ -7,7 +7,7 @@ part of 'generic_website.dart'; // ************************************************************************** String _$genericWebsiteServiceHash() => - r'7dea42d6914b0f06250a96bb425ba10a331ef0d3'; + r'8eab8ab1448d4826dc0cda0ff6b47143735ba176'; /// See also [GenericWebsiteService]. @ProviderFor(GenericWebsiteService) diff --git a/app/lib/extensions/database_table_size.dart b/app/lib/extensions/database_table_size.dart index f9efb902..f5c949d0 100644 --- a/app/lib/extensions/database_table_size.dart +++ b/app/lib/extensions/database_table_size.dart @@ -1,6 +1,6 @@ import 'package:drift/drift.dart'; -extension TableSIze on DatabaseConnectionUser { +extension TableSize on DatabaseConnectionUser { SingleSelectable tableSize(TableInfo table) { return customSelect( 'SELECT SUM(pgsize) /(1024.0 * 1024.0)AS pgsize_mb FROM dbstat WHERE name = ?1', diff --git a/app/lib/features/bangs/data/database/daos/bang.dart b/app/lib/features/bangs/data/database/daos/bang.dart index d37c8e69..1b889a7b 100644 --- a/app/lib/features/bangs/data/database/daos/bang.dart +++ b/app/lib/features/bangs/data/database/daos/bang.dart @@ -94,6 +94,27 @@ class BangDao extends DatabaseAccessor with _$BangDaoMixin { return db.bangQuery(query: db.buildFtsQuery(searchString)); } + Future addSearchEntry(String trigger, String searchQuery) { + return db.bangHistory.insertOne( + BangHistoryCompanion.insert( + searchQuery: searchQuery, + trigger: trigger, + searchDate: DateTime.now(), + ), + onConflict: DoUpdate( + target: [db.bangHistory.searchQuery], + (old) => BangHistoryCompanion.custom( + trigger: Variable(trigger), + searchDate: Variable(DateTime.now()), + ), + ), + ); + } + + Future removeSearchEntry(String searchQuery) { + return db.bangHistory.deleteWhere((t) => t.searchQuery.equals(searchQuery)); + } + // Future upsertBangIcon(String trigger, BrowserIcon icon) async { // //TODO: swithc to WEBP in future, requires 3rd party // final compressed = await icon.image.toByteData(format: ImageByteFormat.png); diff --git a/app/lib/features/bangs/data/database/database.dart b/app/lib/features/bangs/data/database/database.dart index cc73559f..abcba277 100644 --- a/app/lib/features/bangs/data/database/database.dart +++ b/app/lib/features/bangs/data/database/database.dart @@ -4,6 +4,7 @@ import 'package:lensai/features/bangs/data/database/daos/sync.dart'; import 'package:lensai/features/bangs/data/database/drift/converters/bang_format.dart'; import 'package:lensai/features/bangs/data/models/bang.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart'; +import 'package:lensai/features/bangs/data/models/search_history_entry.dart'; import 'package:lensai/features/search/domain/fts_tokenizer.dart'; part 'database.g.dart'; diff --git a/app/lib/features/bangs/data/database/database.drift b/app/lib/features/bangs/data/database/database.drift index bbe08559..e491dd9e 100644 --- a/app/lib/features/bangs/data/database/database.drift +++ b/app/lib/features/bangs/data/database/database.drift @@ -1,6 +1,7 @@ import 'package:lensai/features/bangs/data/database/drift/converters/bang_format.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart'; import 'package:lensai/features/bangs/data/models/bang.dart'; +import 'package:lensai/features/bangs/data/models/search_history_entry.dart'; CREATE TABLE bang ( "trigger" TEXT PRIMARY KEY NOT NULL, @@ -24,10 +25,10 @@ CREATE TABLE bang_frequency ( last_used DATETIME NOT NULL ); -CREATE TABLE bang_icon ( - "trigger" TEXT PRIMARY KEY NOT NULL REFERENCES bang ("trigger") ON DELETE CASCADE, - icon_data BLOB NOT NULL, - fetch_date DATETIME NOT NULL +CREATE TABLE bang_history ( + search_query TEXT UNIQUE NOT NULL, + "trigger" TEXT NOT NULL REFERENCES bang ("trigger"), + search_date DATETIME NOT NULL ); CREATE VIRTUAL TABLE bang_fts @@ -42,14 +43,11 @@ CREATE VIEW bang_data_view WITH BangData AS SELECT b.*, bf.frequency, - bf.last_used, - bi.icon_data + bf.last_used FROM bang b LEFT JOIN - bang_frequency bf ON b."trigger" = bf."trigger" - LEFT JOIN - bang_icon bi ON b."trigger" = bi."trigger"; + bang_frequency bf ON b."trigger" = bf."trigger"; -- Triggers to keep the FTS index up to date. CREATE TRIGGER bang_after_insert AFTER INSERT ON bang BEGIN @@ -78,16 +76,13 @@ bangQuery WITH BangData: SELECT b.*, bf.frequency, - bf.last_used, - bi.icon_data + bf.last_used FROM bang_fts(:query) fts INNER JOIN bang b ON b.rowid = fts.rowid LEFT JOIN bang_frequency bf ON b."trigger" = bf."trigger" - LEFT JOIN - bang_icon bi ON b."trigger" = bi."trigger" ORDER BY RANK, bf.frequency NULLS LAST; @@ -113,4 +108,19 @@ categoriesJson: c.category, json(c.sub_categories) ) AS categories_json - FROM categories c; \ No newline at end of file + FROM categories c; + +searchHistoryEntries WITH SearchHistoryEntry: + SELECT * + FROM bang_history + ORDER BY search_date DESC + LIMIT :limit; + +evictHistoryEntries: + DELETE FROM bang_history + WHERE rowid IN ( + SELECT rowid + FROM bang_history + ORDER BY search_date DESC + LIMIT -1 OFFSET :limit + ); \ No newline at end of file diff --git a/app/lib/features/bangs/data/database/database.g.dart b/app/lib/features/bangs/data/database/database.g.dart index c04f8799..f830e954 100644 --- a/app/lib/features/bangs/data/database/database.g.dart +++ b/app/lib/features/bangs/data/database/database.g.dart @@ -599,171 +599,173 @@ class BangFrequencyCompanion extends UpdateCompanion { } } -class BangIcon extends Table with TableInfo { +class BangHistory extends Table with TableInfo { @override final GeneratedDatabase attachedDatabase; final String? _alias; - BangIcon(this.attachedDatabase, [this._alias]); + BangHistory(this.attachedDatabase, [this._alias]); + late final GeneratedColumn searchQuery = GeneratedColumn( + 'search_query', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'UNIQUE NOT NULL'); late final GeneratedColumn trigger = GeneratedColumn( 'trigger', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, - $customConstraints: - 'PRIMARY KEY NOT NULL REFERENCES bang("trigger")ON DELETE CASCADE'); - late final GeneratedColumn iconData = GeneratedColumn( - 'icon_data', aliasedName, false, - type: DriftSqlType.blob, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL'); - late final GeneratedColumn fetchDate = GeneratedColumn( - 'fetch_date', aliasedName, false, + $customConstraints: 'NOT NULL REFERENCES bang("trigger")'); + late final GeneratedColumn searchDate = GeneratedColumn( + 'search_date', aliasedName, false, type: DriftSqlType.dateTime, requiredDuringInsert: true, $customConstraints: 'NOT NULL'); @override - List get $columns => [trigger, iconData, fetchDate]; + List get $columns => [searchQuery, trigger, searchDate]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'bang_icon'; + static const String $name = 'bang_history'; @override - Set get $primaryKey => {trigger}; + Set get $primaryKey => const {}; @override - BangIconData map(Map data, {String? tablePrefix}) { + BangHistoryData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return BangIconData( + return BangHistoryData( + searchQuery: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}search_query'])!, trigger: attachedDatabase.typeMapping .read(DriftSqlType.string, data['${effectivePrefix}trigger'])!, - iconData: attachedDatabase.typeMapping - .read(DriftSqlType.blob, data['${effectivePrefix}icon_data'])!, - fetchDate: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}fetch_date'])!, + searchDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}search_date'])!, ); } @override - BangIcon createAlias(String alias) { - return BangIcon(attachedDatabase, alias); + BangHistory createAlias(String alias) { + return BangHistory(attachedDatabase, alias); } @override bool get dontWriteConstraints => true; } -class BangIconData extends DataClass implements Insertable { +class BangHistoryData extends DataClass implements Insertable { + final String searchQuery; final String trigger; - final Uint8List iconData; - final DateTime fetchDate; - const BangIconData( - {required this.trigger, required this.iconData, required this.fetchDate}); + final DateTime searchDate; + const BangHistoryData( + {required this.searchQuery, + required this.trigger, + required this.searchDate}); @override Map toColumns(bool nullToAbsent) { final map = {}; + map['search_query'] = Variable(searchQuery); map['trigger'] = Variable(trigger); - map['icon_data'] = Variable(iconData); - map['fetch_date'] = Variable(fetchDate); + map['search_date'] = Variable(searchDate); return map; } - factory BangIconData.fromJson(Map json, + factory BangHistoryData.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; - return BangIconData( + return BangHistoryData( + searchQuery: serializer.fromJson(json['search_query']), trigger: serializer.fromJson(json['trigger']), - iconData: serializer.fromJson(json['icon_data']), - fetchDate: serializer.fromJson(json['fetch_date']), + searchDate: serializer.fromJson(json['search_date']), ); } @override Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { + 'search_query': serializer.toJson(searchQuery), 'trigger': serializer.toJson(trigger), - 'icon_data': serializer.toJson(iconData), - 'fetch_date': serializer.toJson(fetchDate), + 'search_date': serializer.toJson(searchDate), }; } - BangIconData copyWith( - {String? trigger, Uint8List? iconData, DateTime? fetchDate}) => - BangIconData( + BangHistoryData copyWith( + {String? searchQuery, String? trigger, DateTime? searchDate}) => + BangHistoryData( + searchQuery: searchQuery ?? this.searchQuery, trigger: trigger ?? this.trigger, - iconData: iconData ?? this.iconData, - fetchDate: fetchDate ?? this.fetchDate, + searchDate: searchDate ?? this.searchDate, ); - BangIconData copyWithCompanion(BangIconCompanion data) { - return BangIconData( + BangHistoryData copyWithCompanion(BangHistoryCompanion data) { + return BangHistoryData( + searchQuery: + data.searchQuery.present ? data.searchQuery.value : this.searchQuery, trigger: data.trigger.present ? data.trigger.value : this.trigger, - iconData: data.iconData.present ? data.iconData.value : this.iconData, - fetchDate: data.fetchDate.present ? data.fetchDate.value : this.fetchDate, + searchDate: + data.searchDate.present ? data.searchDate.value : this.searchDate, ); } @override String toString() { - return (StringBuffer('BangIconData(') + return (StringBuffer('BangHistoryData(') + ..write('searchQuery: $searchQuery, ') ..write('trigger: $trigger, ') - ..write('iconData: $iconData, ') - ..write('fetchDate: $fetchDate') + ..write('searchDate: $searchDate') ..write(')')) .toString(); } @override - int get hashCode => - Object.hash(trigger, $driftBlobEquality.hash(iconData), fetchDate); + int get hashCode => Object.hash(searchQuery, trigger, searchDate); @override bool operator ==(Object other) => identical(this, other) || - (other is BangIconData && + (other is BangHistoryData && + other.searchQuery == this.searchQuery && other.trigger == this.trigger && - $driftBlobEquality.equals(other.iconData, this.iconData) && - other.fetchDate == this.fetchDate); + other.searchDate == this.searchDate); } -class BangIconCompanion extends UpdateCompanion { +class BangHistoryCompanion extends UpdateCompanion { + final Value searchQuery; final Value trigger; - final Value iconData; - final Value fetchDate; + final Value searchDate; final Value rowid; - const BangIconCompanion({ + const BangHistoryCompanion({ + this.searchQuery = const Value.absent(), this.trigger = const Value.absent(), - this.iconData = const Value.absent(), - this.fetchDate = const Value.absent(), + this.searchDate = const Value.absent(), this.rowid = const Value.absent(), }); - BangIconCompanion.insert({ + BangHistoryCompanion.insert({ + required String searchQuery, required String trigger, - required Uint8List iconData, - required DateTime fetchDate, + required DateTime searchDate, this.rowid = const Value.absent(), - }) : trigger = Value(trigger), - iconData = Value(iconData), - fetchDate = Value(fetchDate); - static Insertable custom({ + }) : searchQuery = Value(searchQuery), + trigger = Value(trigger), + searchDate = Value(searchDate); + static Insertable custom({ + Expression? searchQuery, Expression? trigger, - Expression? iconData, - Expression? fetchDate, + Expression? searchDate, Expression? rowid, }) { return RawValuesInsertable({ + if (searchQuery != null) 'search_query': searchQuery, if (trigger != null) 'trigger': trigger, - if (iconData != null) 'icon_data': iconData, - if (fetchDate != null) 'fetch_date': fetchDate, + if (searchDate != null) 'search_date': searchDate, if (rowid != null) 'rowid': rowid, }); } - BangIconCompanion copyWith( - {Value? trigger, - Value? iconData, - Value? fetchDate, + BangHistoryCompanion copyWith( + {Value? searchQuery, + Value? trigger, + Value? searchDate, Value? rowid}) { - return BangIconCompanion( + return BangHistoryCompanion( + searchQuery: searchQuery ?? this.searchQuery, trigger: trigger ?? this.trigger, - iconData: iconData ?? this.iconData, - fetchDate: fetchDate ?? this.fetchDate, + searchDate: searchDate ?? this.searchDate, rowid: rowid ?? this.rowid, ); } @@ -771,14 +773,14 @@ class BangIconCompanion extends UpdateCompanion { @override Map toColumns(bool nullToAbsent) { final map = {}; + if (searchQuery.present) { + map['search_query'] = Variable(searchQuery.value); + } if (trigger.present) { map['trigger'] = Variable(trigger.value); } - if (iconData.present) { - map['icon_data'] = Variable(iconData.value); - } - if (fetchDate.present) { - map['fetch_date'] = Variable(fetchDate.value); + if (searchDate.present) { + map['search_date'] = Variable(searchDate.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -788,10 +790,10 @@ class BangIconCompanion extends UpdateCompanion { @override String toString() { - return (StringBuffer('BangIconCompanion(') + return (StringBuffer('BangHistoryCompanion(') + ..write('searchQuery: $searchQuery, ') ..write('trigger: $trigger, ') - ..write('iconData: $iconData, ') - ..write('fetchDate: $fetchDate, ') + ..write('searchDate: $searchDate, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -985,8 +987,7 @@ class BangDataView extends ViewInfo subCategory, format, frequency, - lastUsed, - iconData + lastUsed ]; @override String get aliasedName => _alias ?? entityName; @@ -995,7 +996,7 @@ class BangDataView extends ViewInfo @override Map get createViewStatements => { SqlDialect.sqlite: - 'CREATE VIEW bang_data_view AS SELECT b.*, bf.frequency, bf.last_used, bi.icon_data FROM bang AS b LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger" LEFT JOIN bang_icon AS bi ON b."trigger" = bi."trigger"', + 'CREATE VIEW bang_data_view AS SELECT b.*, bf.frequency, bf.last_used FROM bang AS b LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger"', }; @override BangDataView get asDslTable => this; @@ -1055,9 +1056,6 @@ class BangDataView extends ViewInfo late final GeneratedColumn lastUsed = GeneratedColumn( 'last_used', aliasedName, true, type: DriftSqlType.dateTime); - late final GeneratedColumn iconData = GeneratedColumn( - 'icon_data', aliasedName, true, - type: DriftSqlType.blob); @override BangDataView createAlias(String alias) { return BangDataView(attachedDatabase, alias); @@ -1066,7 +1064,7 @@ class BangDataView extends ViewInfo @override Query? get query => null; @override - Set get readTables => const {'bang', 'bang_frequency', 'bang_icon'}; + Set get readTables => const {'bang', 'bang_frequency'}; } abstract class _$BangDatabase extends GeneratedDatabase { @@ -1075,7 +1073,7 @@ abstract class _$BangDatabase extends GeneratedDatabase { late final BangTable bang = BangTable(this); late final BangSync bangSync = BangSync(this); late final BangFrequency bangFrequency = BangFrequency(this); - late final BangIcon bangIcon = BangIcon(this); + late final BangHistory bangHistory = BangHistory(this); late final BangFts bangFts = BangFts(this); late final BangDataView bangDataView = BangDataView(this); late final Trigger bangAfterInsert = Trigger( @@ -1099,13 +1097,12 @@ abstract class _$BangDatabase extends GeneratedDatabase { Selectable bangQuery({required String query}) { return customSelect( - 'SELECT b.*, bf.frequency, bf.last_used, bi.icon_data FROM bang_fts(?1)AS fts INNER JOIN bang AS b ON b."rowid" = fts."rowid" LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger" LEFT JOIN bang_icon AS bi ON b."trigger" = bi."trigger" ORDER BY RANK, bf.frequency NULLS LAST', + 'SELECT b.*, bf.frequency, bf.last_used FROM bang_fts(?1)AS fts INNER JOIN bang AS b ON b."rowid" = fts."rowid" LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger" ORDER BY RANK, bf.frequency NULLS LAST', variables: [ Variable(query) ], readsFrom: { bangFrequency, - bangIcon, bang, bangFts, }).map((QueryRow row) => BangData( @@ -1131,6 +1128,30 @@ abstract class _$BangDatabase extends GeneratedDatabase { }).map((QueryRow row) => row.read('categories_json')); } + Selectable searchHistoryEntries({required int limit}) { + return customSelect( + 'SELECT * FROM bang_history ORDER BY search_date DESC LIMIT ?1', + variables: [ + Variable(limit) + ], + readsFrom: { + bangHistory, + }).map((QueryRow row) => SearchHistoryEntry( + searchQuery: row.read('search_query'), + trigger: row.read('trigger'), + searchDate: row.read('search_date'), + )); + } + + Future evictHistoryEntries({required int limit}) { + return customUpdate( + 'DELETE FROM bang_history WHERE "rowid" IN (SELECT "rowid" FROM bang_history ORDER BY search_date DESC LIMIT -1 OFFSET ?1)', + variables: [Variable(limit)], + updates: {bangHistory}, + updateKind: UpdateKind.delete, + ); + } + @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -1139,7 +1160,7 @@ abstract class _$BangDatabase extends GeneratedDatabase { bang, bangSync, bangFrequency, - bangIcon, + bangHistory, bangFts, bangDataView, bangAfterInsert, @@ -1156,13 +1177,6 @@ abstract class _$BangDatabase extends GeneratedDatabase { TableUpdate('bang_frequency', kind: UpdateKind.delete), ], ), - WritePropagation( - on: TableUpdateQuery.onTableName('bang', - limitUpdateKind: UpdateKind.delete), - result: [ - TableUpdate('bang_icon', kind: UpdateKind.delete), - ], - ), WritePropagation( on: TableUpdateQuery.onTableName('bang', limitUpdateKind: UpdateKind.insert), @@ -1230,17 +1244,17 @@ final class $BangTableReferences manager.$state.copyWith(prefetchedData: cache)); } - static MultiTypedResultKey> _bangIconRefsTable( - _$BangDatabase db) => - MultiTypedResultKey.fromTable(db.bangIcon, + static MultiTypedResultKey> + _bangHistoryRefsTable(_$BangDatabase db) => MultiTypedResultKey.fromTable( + db.bangHistory, aliasName: - $_aliasNameGenerator(db.bang.trigger, db.bangIcon.trigger)); + $_aliasNameGenerator(db.bang.trigger, db.bangHistory.trigger)); - $BangIconProcessedTableManager get bangIconRefs { - final manager = $BangIconTableManager($_db, $_db.bangIcon) + $BangHistoryProcessedTableManager get bangHistoryRefs { + final manager = $BangHistoryTableManager($_db, $_db.bangHistory) .filter((f) => f.trigger.trigger($_item.trigger)); - final cache = $_typedResult.readTableOrNull(_bangIconRefsTable($_db)); + final cache = $_typedResult.readTableOrNull(_bangHistoryRefsTable($_db)); return ProcessedTableManager( manager.$state.copyWith(prefetchedData: cache)); } @@ -1303,19 +1317,19 @@ class $BangTableFilterComposer extends Composer<_$BangDatabase, BangTable> { return f(composer); } - Expression bangIconRefs( - Expression Function($BangIconFilterComposer f) f) { - final $BangIconFilterComposer composer = $composerBuilder( + Expression bangHistoryRefs( + Expression Function($BangHistoryFilterComposer f) f) { + final $BangHistoryFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.trigger, - referencedTable: $db.bangIcon, + referencedTable: $db.bangHistory, getReferencedColumn: (t) => t.trigger, builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => - $BangIconFilterComposer( + $BangHistoryFilterComposer( $db: $db, - $table: $db.bangIcon, + $table: $db.bangHistory, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -1411,19 +1425,19 @@ class $BangTableAnnotationComposer extends Composer<_$BangDatabase, BangTable> { return f(composer); } - Expression bangIconRefs( - Expression Function($BangIconAnnotationComposer a) f) { - final $BangIconAnnotationComposer composer = $composerBuilder( + Expression bangHistoryRefs( + Expression Function($BangHistoryAnnotationComposer a) f) { + final $BangHistoryAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.trigger, - referencedTable: $db.bangIcon, + referencedTable: $db.bangHistory, getReferencedColumn: (t) => t.trigger, builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => - $BangIconAnnotationComposer( + $BangHistoryAnnotationComposer( $db: $db, - $table: $db.bangIcon, + $table: $db.bangHistory, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -1444,7 +1458,7 @@ class $BangTableTableManager extends RootTableManager< $BangTableUpdateCompanionBuilder, (Bang, $BangTableReferences), Bang, - PrefetchHooks Function({bool bangFrequencyRefs, bool bangIconRefs})> { + PrefetchHooks Function({bool bangFrequencyRefs, bool bangHistoryRefs})> { $BangTableTableManager(_$BangDatabase db, BangTable table) : super(TableManagerState( db: db, @@ -1504,12 +1518,12 @@ class $BangTableTableManager extends RootTableManager< (e.readTable(table), $BangTableReferences(db, table, e))) .toList(), prefetchHooksCallback: ( - {bangFrequencyRefs = false, bangIconRefs = false}) { + {bangFrequencyRefs = false, bangHistoryRefs = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [ if (bangFrequencyRefs) db.bangFrequency, - if (bangIconRefs) db.bangIcon + if (bangHistoryRefs) db.bangHistory ], addJoins: null, getPrefetchedDataCallback: (items) async { @@ -1526,13 +1540,13 @@ class $BangTableTableManager extends RootTableManager< (item, referencedItems) => referencedItems .where((e) => e.trigger == item.trigger), typedResults: items), - if (bangIconRefs) + if (bangHistoryRefs) await $_getPrefetchedData( currentTable: table, referencedTable: - $BangTableReferences._bangIconRefsTable(db), + $BangTableReferences._bangHistoryRefsTable(db), managerFromTypedResult: (p0) => - $BangTableReferences(db, table, p0).bangIconRefs, + $BangTableReferences(db, table, p0).bangHistoryRefs, referencedItemsForCurrentItem: (item, referencedItems) => referencedItems .where((e) => e.trigger == item.trigger), @@ -1555,7 +1569,7 @@ typedef $BangTableProcessedTableManager = ProcessedTableManager< $BangTableUpdateCompanionBuilder, (Bang, $BangTableReferences), Bang, - PrefetchHooks Function({bool bangFrequencyRefs, bool bangIconRefs})>; + PrefetchHooks Function({bool bangFrequencyRefs, bool bangHistoryRefs})>; typedef $BangSyncCreateCompanionBuilder = BangSyncCompanion Function({ Value group, required DateTime lastSync, @@ -1906,25 +1920,25 @@ typedef $BangFrequencyProcessedTableManager = ProcessedTableManager< (BangFrequencyData, $BangFrequencyReferences), BangFrequencyData, PrefetchHooks Function({bool trigger})>; -typedef $BangIconCreateCompanionBuilder = BangIconCompanion Function({ +typedef $BangHistoryCreateCompanionBuilder = BangHistoryCompanion Function({ + required String searchQuery, required String trigger, - required Uint8List iconData, - required DateTime fetchDate, + required DateTime searchDate, Value rowid, }); -typedef $BangIconUpdateCompanionBuilder = BangIconCompanion Function({ +typedef $BangHistoryUpdateCompanionBuilder = BangHistoryCompanion Function({ + Value searchQuery, Value trigger, - Value iconData, - Value fetchDate, + Value searchDate, Value rowid, }); -final class $BangIconReferences - extends BaseReferences<_$BangDatabase, BangIcon, BangIconData> { - $BangIconReferences(super.$_db, super.$_table, super.$_typedResult); +final class $BangHistoryReferences + extends BaseReferences<_$BangDatabase, BangHistory, BangHistoryData> { + $BangHistoryReferences(super.$_db, super.$_table, super.$_typedResult); - static BangTable _triggerTable(_$BangDatabase db) => db.bang - .createAlias($_aliasNameGenerator(db.bangIcon.trigger, db.bang.trigger)); + static BangTable _triggerTable(_$BangDatabase db) => db.bang.createAlias( + $_aliasNameGenerator(db.bangHistory.trigger, db.bang.trigger)); $BangTableProcessedTableManager get trigger { final manager = $BangTableTableManager($_db, $_db.bang) @@ -1936,19 +1950,19 @@ final class $BangIconReferences } } -class $BangIconFilterComposer extends Composer<_$BangDatabase, BangIcon> { - $BangIconFilterComposer({ +class $BangHistoryFilterComposer extends Composer<_$BangDatabase, BangHistory> { + $BangHistoryFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get iconData => $composableBuilder( - column: $table.iconData, builder: (column) => ColumnFilters(column)); + ColumnFilters get searchQuery => $composableBuilder( + column: $table.searchQuery, builder: (column) => ColumnFilters(column)); - ColumnFilters get fetchDate => $composableBuilder( - column: $table.fetchDate, builder: (column) => ColumnFilters(column)); + ColumnFilters get searchDate => $composableBuilder( + column: $table.searchDate, builder: (column) => ColumnFilters(column)); $BangTableFilterComposer get trigger { final $BangTableFilterComposer composer = $composerBuilder( @@ -1971,19 +1985,20 @@ class $BangIconFilterComposer extends Composer<_$BangDatabase, BangIcon> { } } -class $BangIconOrderingComposer extends Composer<_$BangDatabase, BangIcon> { - $BangIconOrderingComposer({ +class $BangHistoryOrderingComposer + extends Composer<_$BangDatabase, BangHistory> { + $BangHistoryOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get iconData => $composableBuilder( - column: $table.iconData, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get searchQuery => $composableBuilder( + column: $table.searchQuery, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get fetchDate => $composableBuilder( - column: $table.fetchDate, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get searchDate => $composableBuilder( + column: $table.searchDate, builder: (column) => ColumnOrderings(column)); $BangTableOrderingComposer get trigger { final $BangTableOrderingComposer composer = $composerBuilder( @@ -2006,19 +2021,20 @@ class $BangIconOrderingComposer extends Composer<_$BangDatabase, BangIcon> { } } -class $BangIconAnnotationComposer extends Composer<_$BangDatabase, BangIcon> { - $BangIconAnnotationComposer({ +class $BangHistoryAnnotationComposer + extends Composer<_$BangDatabase, BangHistory> { + $BangHistoryAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get iconData => - $composableBuilder(column: $table.iconData, builder: (column) => column); + GeneratedColumn get searchQuery => $composableBuilder( + column: $table.searchQuery, builder: (column) => column); - GeneratedColumn get fetchDate => - $composableBuilder(column: $table.fetchDate, builder: (column) => column); + GeneratedColumn get searchDate => $composableBuilder( + column: $table.searchDate, builder: (column) => column); $BangTableAnnotationComposer get trigger { final $BangTableAnnotationComposer composer = $composerBuilder( @@ -2041,55 +2057,55 @@ class $BangIconAnnotationComposer extends Composer<_$BangDatabase, BangIcon> { } } -class $BangIconTableManager extends RootTableManager< +class $BangHistoryTableManager extends RootTableManager< _$BangDatabase, - BangIcon, - BangIconData, - $BangIconFilterComposer, - $BangIconOrderingComposer, - $BangIconAnnotationComposer, - $BangIconCreateCompanionBuilder, - $BangIconUpdateCompanionBuilder, - (BangIconData, $BangIconReferences), - BangIconData, + BangHistory, + BangHistoryData, + $BangHistoryFilterComposer, + $BangHistoryOrderingComposer, + $BangHistoryAnnotationComposer, + $BangHistoryCreateCompanionBuilder, + $BangHistoryUpdateCompanionBuilder, + (BangHistoryData, $BangHistoryReferences), + BangHistoryData, PrefetchHooks Function({bool trigger})> { - $BangIconTableManager(_$BangDatabase db, BangIcon table) + $BangHistoryTableManager(_$BangDatabase db, BangHistory table) : super(TableManagerState( db: db, table: table, createFilteringComposer: () => - $BangIconFilterComposer($db: db, $table: table), + $BangHistoryFilterComposer($db: db, $table: table), createOrderingComposer: () => - $BangIconOrderingComposer($db: db, $table: table), + $BangHistoryOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $BangIconAnnotationComposer($db: db, $table: table), + $BangHistoryAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ + Value searchQuery = const Value.absent(), Value trigger = const Value.absent(), - Value iconData = const Value.absent(), - Value fetchDate = const Value.absent(), + Value searchDate = const Value.absent(), Value rowid = const Value.absent(), }) => - BangIconCompanion( + BangHistoryCompanion( + searchQuery: searchQuery, trigger: trigger, - iconData: iconData, - fetchDate: fetchDate, + searchDate: searchDate, rowid: rowid, ), createCompanionCallback: ({ + required String searchQuery, required String trigger, - required Uint8List iconData, - required DateTime fetchDate, + required DateTime searchDate, Value rowid = const Value.absent(), }) => - BangIconCompanion.insert( + BangHistoryCompanion.insert( + searchQuery: searchQuery, trigger: trigger, - iconData: iconData, - fetchDate: fetchDate, + searchDate: searchDate, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map((e) => - (e.readTable(table), $BangIconReferences(db, table, e))) + (e.readTable(table), $BangHistoryReferences(db, table, e))) .toList(), prefetchHooksCallback: ({trigger = false}) { return PrefetchHooks( @@ -2112,9 +2128,9 @@ class $BangIconTableManager extends RootTableManager< state = state.withJoin( currentTable: table, currentColumn: table.trigger, - referencedTable: $BangIconReferences._triggerTable(db), + referencedTable: $BangHistoryReferences._triggerTable(db), referencedColumn: - $BangIconReferences._triggerTable(db).trigger, + $BangHistoryReferences._triggerTable(db).trigger, ) as T; } @@ -2128,17 +2144,17 @@ class $BangIconTableManager extends RootTableManager< )); } -typedef $BangIconProcessedTableManager = ProcessedTableManager< +typedef $BangHistoryProcessedTableManager = ProcessedTableManager< _$BangDatabase, - BangIcon, - BangIconData, - $BangIconFilterComposer, - $BangIconOrderingComposer, - $BangIconAnnotationComposer, - $BangIconCreateCompanionBuilder, - $BangIconUpdateCompanionBuilder, - (BangIconData, $BangIconReferences), - BangIconData, + BangHistory, + BangHistoryData, + $BangHistoryFilterComposer, + $BangHistoryOrderingComposer, + $BangHistoryAnnotationComposer, + $BangHistoryCreateCompanionBuilder, + $BangHistoryUpdateCompanionBuilder, + (BangHistoryData, $BangHistoryReferences), + BangHistoryData, PrefetchHooks Function({bool trigger})>; typedef $BangFtsCreateCompanionBuilder = BangFtsCompanion Function({ required String trigger, @@ -2266,7 +2282,7 @@ class $BangDatabaseManager { $BangSyncTableManager(_db, _db.bangSync); $BangFrequencyTableManager get bangFrequency => $BangFrequencyTableManager(_db, _db.bangFrequency); - $BangIconTableManager get bangIcon => - $BangIconTableManager(_db, _db.bangIcon); + $BangHistoryTableManager get bangHistory => + $BangHistoryTableManager(_db, _db.bangHistory); $BangFtsTableManager get bangFts => $BangFtsTableManager(_db, _db.bangFts); } diff --git a/app/lib/features/bangs/data/models/search_history_entry.dart b/app/lib/features/bangs/data/models/search_history_entry.dart new file mode 100644 index 00000000..0e680b8c --- /dev/null +++ b/app/lib/features/bangs/data/models/search_history_entry.dart @@ -0,0 +1,23 @@ +import 'package:fast_equatable/fast_equatable.dart'; + +class SearchHistoryEntry with FastEquatable { + final String searchQuery; + final String trigger; + final DateTime searchDate; + + SearchHistoryEntry({ + required this.searchQuery, + required this.trigger, + required this.searchDate, + }); + + @override + bool get cacheHash => true; + + @override + List get hashParameters => [ + searchQuery, + trigger, + searchDate, + ]; +} diff --git a/app/lib/features/bangs/domain/providers/bangs.dart b/app/lib/features/bangs/domain/providers/bangs.dart index 496cdb11..4734617f 100644 --- a/app/lib/features/bangs/domain/providers/bangs.dart +++ b/app/lib/features/bangs/domain/providers/bangs.dart @@ -1,5 +1,6 @@ import 'package:lensai/features/bangs/data/models/bang.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart'; +import 'package:lensai/features/bangs/data/models/search_history_entry.dart'; import 'package:lensai/features/bangs/domain/repositories/data.dart'; import 'package:lensai/features/bangs/domain/repositories/sync.dart'; import 'package:riverpod/riverpod.dart'; @@ -50,6 +51,12 @@ Stream> frequentBangDataList(Ref ref) { return repository.watchFrequentBangs(); } +@Riverpod() +Stream> searchHistory(Ref ref) { + final repository = ref.watch(bangDataRepositoryProvider.notifier); + return repository.watchSearchHistory(limit: 3); //TODO: make count dynamic +} + @Riverpod() Future bangDataEnsureIcon( Ref ref, @@ -78,9 +85,3 @@ Stream bangCountOfGroup( final repository = ref.watch(bangDataRepositoryProvider.notifier); return repository.watchBangCount(group); } - -@Riverpod() -Stream bangIconCacheSizeMegabytes(Ref ref) { - final repository = ref.watch(bangDataRepositoryProvider.notifier); - return repository.watchIconCacheSize(); -} diff --git a/app/lib/features/bangs/domain/providers/bangs.g.dart b/app/lib/features/bangs/domain/providers/bangs.g.dart index e50ba4bc..bc57e9b5 100644 --- a/app/lib/features/bangs/domain/providers/bangs.g.dart +++ b/app/lib/features/bangs/domain/providers/bangs.g.dart @@ -368,6 +368,25 @@ final frequentBangDataListProvider = @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef FrequentBangDataListRef = AutoDisposeStreamProviderRef>; +String _$searchHistoryHash() => r'7b97798729643de8e44fd8024cdc02f35124b08b'; + +/// See also [searchHistory]. +@ProviderFor(searchHistory) +final searchHistoryProvider = + AutoDisposeStreamProvider>.internal( + searchHistory, + name: r'searchHistoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$searchHistoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SearchHistoryRef + = AutoDisposeStreamProviderRef>; String _$bangDataEnsureIconHash() => r'423b8e92fd89ec328c2fcc8f9e55365c30633fd2'; @@ -760,25 +779,5 @@ class _BangCountOfGroupProviderElement @override BangGroup get group => (origin as BangCountOfGroupProvider).group; } - -String _$bangIconCacheSizeMegabytesHash() => - r'e08bfdc7bb47a56c079464478f0b8b5516b2effe'; - -/// See also [bangIconCacheSizeMegabytes]. -@ProviderFor(bangIconCacheSizeMegabytes) -final bangIconCacheSizeMegabytesProvider = - AutoDisposeStreamProvider.internal( - bangIconCacheSizeMegabytes, - name: r'bangIconCacheSizeMegabytesProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$bangIconCacheSizeMegabytesHash, - dependencies: null, - allTransitiveDependencies: null, -); - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -typedef BangIconCacheSizeMegabytesRef = AutoDisposeStreamProviderRef; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/bangs/domain/providers/search.dart b/app/lib/features/bangs/domain/providers/search.dart new file mode 100644 index 00000000..727f155c --- /dev/null +++ b/app/lib/features/bangs/domain/providers/search.dart @@ -0,0 +1,24 @@ +import 'package:lensai/features/bangs/data/models/bang_data.dart'; +import 'package:lensai/features/bangs/domain/repositories/data.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'search.g.dart'; + +@Riverpod() +Future triggerBangSearch( + Ref ref, + BangData bang, + String searchQuery, +) async { + final bangDataNotifier = ref.watch(bangDataRepositoryProvider.notifier); + + await bangDataNotifier.increaseFrequency(bang.trigger); + await bangDataNotifier.addSearchEntry( + bang.trigger, + searchQuery, + maxEntryCount: 3, + ); //TODO: make count dynamic + + return bang.getUrl(searchQuery); +} diff --git a/app/lib/features/bangs/domain/providers/search.g.dart b/app/lib/features/bangs/domain/providers/search.g.dart new file mode 100644 index 00000000..f8712133 --- /dev/null +++ b/app/lib/features/bangs/domain/providers/search.g.dart @@ -0,0 +1,177 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$triggerBangSearchHash() => r'a5cff116ae09b26d4fa476ce6a2e6052fa6fa177'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [triggerBangSearch]. +@ProviderFor(triggerBangSearch) +const triggerBangSearchProvider = TriggerBangSearchFamily(); + +/// See also [triggerBangSearch]. +class TriggerBangSearchFamily extends Family> { + /// See also [triggerBangSearch]. + const TriggerBangSearchFamily(); + + /// See also [triggerBangSearch]. + TriggerBangSearchProvider call( + BangData bang, + String searchQuery, + ) { + return TriggerBangSearchProvider( + bang, + searchQuery, + ); + } + + @override + TriggerBangSearchProvider getProviderOverride( + covariant TriggerBangSearchProvider provider, + ) { + return call( + provider.bang, + provider.searchQuery, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'triggerBangSearchProvider'; +} + +/// See also [triggerBangSearch]. +class TriggerBangSearchProvider extends AutoDisposeFutureProvider { + /// See also [triggerBangSearch]. + TriggerBangSearchProvider( + BangData bang, + String searchQuery, + ) : this._internal( + (ref) => triggerBangSearch( + ref as TriggerBangSearchRef, + bang, + searchQuery, + ), + from: triggerBangSearchProvider, + name: r'triggerBangSearchProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$triggerBangSearchHash, + dependencies: TriggerBangSearchFamily._dependencies, + allTransitiveDependencies: + TriggerBangSearchFamily._allTransitiveDependencies, + bang: bang, + searchQuery: searchQuery, + ); + + TriggerBangSearchProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.bang, + required this.searchQuery, + }) : super.internal(); + + final BangData bang; + final String searchQuery; + + @override + Override overrideWith( + FutureOr Function(TriggerBangSearchRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: TriggerBangSearchProvider._internal( + (ref) => create(ref as TriggerBangSearchRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + bang: bang, + searchQuery: searchQuery, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _TriggerBangSearchProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is TriggerBangSearchProvider && + other.bang == bang && + other.searchQuery == searchQuery; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, bang.hashCode); + hash = _SystemHash.combine(hash, searchQuery.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin TriggerBangSearchRef on AutoDisposeFutureProviderRef { + /// The parameter `bang` of this provider. + BangData get bang; + + /// The parameter `searchQuery` of this provider. + String get searchQuery; +} + +class _TriggerBangSearchProviderElement + extends AutoDisposeFutureProviderElement with TriggerBangSearchRef { + _TriggerBangSearchProviderElement(super.provider); + + @override + BangData get bang => (origin as TriggerBangSearchProvider).bang; + @override + String get searchQuery => (origin as TriggerBangSearchProvider).searchQuery; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/bangs/domain/repositories/data.dart b/app/lib/features/bangs/domain/repositories/data.dart index bfa31594..b91ae862 100644 --- a/app/lib/features/bangs/domain/repositories/data.dart +++ b/app/lib/features/bangs/domain/repositories/data.dart @@ -3,10 +3,10 @@ import 'dart:convert'; import 'package:drift/drift.dart'; import 'package:exceptions/exceptions.dart'; import 'package:lensai/domain/services/generic_website.dart'; -import 'package:lensai/extensions/database_table_size.dart'; import 'package:lensai/features/bangs/data/database/database.dart'; import 'package:lensai/features/bangs/data/models/bang.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart'; +import 'package:lensai/features/bangs/data/models/search_history_entry.dart'; import 'package:lensai/features/bangs/data/providers.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -63,29 +63,50 @@ class BangDataRepository extends _$BangDataRepository { return _db.bangDao.getFrequentBangDataList(groups: groups).watch(); } + Stream> watchSearchHistory({required int limit}) { + return _db.searchHistoryEntries(limit: limit).watch(); + } + Future increaseFrequency(String trigger) { return _db.bangDao.increaseBangFrequency(trigger); } + Future addSearchEntry( + String trigger, + String searchQuery, { + required int maxEntryCount, + }) { + //Pack in a transaction to bundle rebuilds of watch() queries + return _db.transaction( + () async { + await _db.bangDao.addSearchEntry(trigger, searchQuery); + await _db.evictHistoryEntries(limit: maxEntryCount); + }, + ); + } + + Future removeSearchEntry(String searchQuery) { + return _db.bangDao.removeSearchEntry(searchQuery); + } + Future> ensureIconAvailable(BangData bang) async { if (bang.icon != null) { return Result.success(bang); } - final icon = await ref - .read(genericWebsiteServiceProvider.notifier) - .getUrlFavicon(url: Uri.parse(bang.getUrl('').origin)); + final url = bang.getUrl(''); - // await _db.bangDao.upsertBangIcon(bang.trigger, icon); - return Result.success(bang.copyWith.icon(icon)); - } + final websiteProvider = ref.read(genericWebsiteServiceProvider.notifier); + final cachedIcon = await websiteProvider.getCachedIcon(url); - Stream watchIconCacheSize() { - return _db.tableSize(_db.bangIcon).watchSingle(); - } + if (cachedIcon != null) { + return Result.success(bang.copyWith.icon(cachedIcon)); + } - Future clearIconData() { - return _db.bangIcon.deleteAll(); + return websiteProvider.fetchPageInfo(url).then( + (result) => result + .flatMap((pageInfo) => bang.copyWith.icon(pageInfo.favicon)), + ); } Future resetFrequencies() { diff --git a/app/lib/features/bangs/domain/repositories/data.g.dart b/app/lib/features/bangs/domain/repositories/data.g.dart index b665682a..2b150e45 100644 --- a/app/lib/features/bangs/domain/repositories/data.g.dart +++ b/app/lib/features/bangs/domain/repositories/data.g.dart @@ -7,7 +7,7 @@ part of 'data.dart'; // ************************************************************************** String _$bangDataRepositoryHash() => - r'abacc6db3a61958cae67a91cbfd312ae87c641b3'; + r'b51786552a812ddd54d1418450fe03ff89351e28'; /// See also [BangDataRepository]. @ProviderFor(BangDataRepository) diff --git a/app/lib/features/bangs/presentation/screens/search.dart b/app/lib/features/bangs/presentation/screens/search.dart index b4a94f31..eec5818f 100644 --- a/app/lib/features/bangs/presentation/screens/search.dart +++ b/app/lib/features/bangs/presentation/screens/search.dart @@ -8,8 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/features/bangs/domain/repositories/search.dart'; import 'package:lensai/features/bangs/presentation/widgets/bang_details.dart'; import 'package:lensai/features/geckoview/features/browser/domain/providers.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; +import 'package:lensai/features/user/domain/repositories/settings.dart'; import 'package:lensai/presentation/hooks/listenable_callback.dart'; import 'package:lensai/presentation/widgets/failure_widget.dart'; @@ -22,9 +21,7 @@ class BangSearchScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final resultsAsync = ref.watch(bangSearchProvider); final incognitoEnabled = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode, - ), + settingsRepositoryProvider.select((value) => value.incognitoMode), ); final textEditingController = diff --git a/app/lib/features/bangs/presentation/widgets/bang_details.dart b/app/lib/features/bangs/presentation/widgets/bang_details.dart index f3c3faf3..0ad01526 100644 --- a/app/lib/features/bangs/presentation/widgets/bang_details.dart +++ b/app/lib/features/bangs/presentation/widgets/bang_details.dart @@ -5,9 +5,6 @@ import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart'; import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart'; import 'package:lensai/features/geckoview/domain/repositories/tab.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/utils/ui_helper.dart' as ui_helper; class BangDetails extends HookConsumerWidget { final BangData bangData; @@ -71,24 +68,12 @@ class BangDetails extends HookConsumerWidget { onPressed: () async { final url = Uri.parse(bangData.getUrl('').origin); - final launchExternal = ref.read( - settingsRepositoryProvider.select( - (value) => - (value.valueOrNull ?? Settings.withDefaults()) - .launchUrlExternal, - ), - ); + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: url); - if (launchExternal) { - await ui_helper.launchUrlFeedback(context, url); - } else { - await ref - .read(tabRepositoryProvider.notifier) - .addTab(url: url); - - if (context.mounted) { - context.go(BrowserRoute().location); - } + if (context.mounted) { + context.go(BrowserRoute().location); } }, label: Text(bangData.domain), diff --git a/app/lib/features/bangs/presentation/widgets/search_field.dart b/app/lib/features/bangs/presentation/widgets/search_field.dart index af705f64..2fefadb6 100644 --- a/app/lib/features/bangs/presentation/widgets/search_field.dart +++ b/app/lib/features/bangs/presentation/widgets/search_field.dart @@ -1,14 +1,12 @@ // The default Material-style Autocomplete options. import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart'; -import 'package:lensai/features/search/domain/providers/search_suggestions.dart'; import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart'; import 'package:lensai/features/geckoview/features/browser/presentation/widgets/speech_to_text_button.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; +import 'package:lensai/features/search/domain/providers/search_suggestions.dart'; +import 'package:lensai/features/user/domain/repositories/settings.dart'; import 'package:lensai/presentation/widgets/autocomplete.dart'; class SearchField extends HookConsumerWidget { @@ -31,19 +29,17 @@ class SearchField extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final incognitoEnabled = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode, - ), + settingsRepositoryProvider.select((value) => value.incognitoMode), ); final optionsStream = ref.watch(searchSuggestionsProvider()); final focusNode = useFocusNode(); - final quickAnswer = useListenableSelector( - textController, - () => textController.text.endsWith('?'), - ); + // final quickAnswer = useListenableSelector( + // textController, + // () => textController.text.endsWith('?'), + // ); return Column( mainAxisSize: MainAxisSize.min, @@ -89,7 +85,7 @@ class SearchField extends HookConsumerWidget { child: BangIcon(activeBang!, iconSize: 24.0), ) : null, - label: const Text('Query'), + // label: const Text('Query'), hintText: 'Ask anything...', floatingLabelBehavior: FloatingLabelBehavior.always, suffixIcon: SpeechToTextButton( @@ -113,21 +109,21 @@ class SearchField extends HookConsumerWidget { ); }, ), - if (activeBang?.domain.endsWith('kagi.com') == true) - SwitchListTile( - value: quickAnswer, - onChanged: (_) { - if (textController.text.endsWith('?')) { - textController.text = textController.text - .substring(0, textController.text.length - 1); - } else { - textController.text = '${textController.text}?'; - } - }, - contentPadding: EdgeInsets.zero, - title: const Text('Quick Answer'), - secondary: const Icon(MdiIcons.lightningBolt), - ), + // if (activeBang?.domain.endsWith('kagi.com') == true) + // SwitchListTile( + // value: quickAnswer, + // onChanged: (_) { + // if (textController.text.endsWith('?')) { + // textController.text = textController.text + // .substring(0, textController.text.length - 1); + // } else { + // textController.text = '${textController.text}?'; + // } + // }, + // contentPadding: EdgeInsets.zero, + // title: const Text('Quick Answer'), + // secondary: const Icon(MdiIcons.lightningBolt), + // ), ], ); } diff --git a/app/lib/features/bangs/presentation/widgets/site_search.dart b/app/lib/features/bangs/presentation/widgets/site_search.dart index 96cca82b..0e672030 100644 --- a/app/lib/features/bangs/presentation/widgets/site_search.dart +++ b/app/lib/features/bangs/presentation/widgets/site_search.dart @@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart'; -import 'package:lensai/features/bangs/domain/repositories/data.dart'; +import 'package:lensai/features/bangs/domain/providers/search.dart'; import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart'; import 'package:lensai/features/bangs/presentation/widgets/search_field.dart'; import 'package:lensai/features/geckoview/domain/repositories/tab.dart'; @@ -36,13 +36,11 @@ class SiteSearch extends HookConsumerWidget { Future submitSearch() async { if (activeBang != null && (formKey.currentState?.validate() == true)) { - await ref - .read(bangDataRepositoryProvider.notifier) - .increaseFrequency(activeBang.trigger); + final searchUri = await ref.read( + triggerBangSearchProvider(activeBang, textController.text).future, + ); - await ref - .read(tabRepositoryProvider.notifier) - .addTab(url: activeBang.getUrl(textController.text)); + await ref.read(tabRepositoryProvider.notifier).addTab(url: searchUri); ref.read(overlayDialogControllerProvider.notifier).dismiss(); } diff --git a/app/lib/features/chat_archive/presentation/screens/detail.dart b/app/lib/features/chat_archive/presentation/screens/detail.dart index 5b342657..c07d940a 100644 --- a/app/lib/features/chat_archive/presentation/screens/detail.dart +++ b/app/lib/features/chat_archive/presentation/screens/detail.dart @@ -11,10 +11,7 @@ import 'package:lensai/features/chat_archive/domain/entities/chat_entity.dart'; import 'package:lensai/features/chat_archive/domain/repositories/archive.dart'; import 'package:lensai/features/chat_archive/utils/markdown_to_text.dart'; import 'package:lensai/features/geckoview/domain/repositories/tab.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; import 'package:lensai/presentation/widgets/failure_widget.dart'; -import 'package:lensai/utils/ui_helper.dart' as ui_helper; import 'package:skeletonizer/skeletonizer.dart'; class ChatArchiveDetailScreen extends HookConsumerWidget { @@ -103,23 +100,12 @@ class ChatArchiveDetailScreen extends HookConsumerWidget { onTapLink: (text, href, title) async { if (href != null) { if (Uri.parse(href) case final Uri url) { - final launchExternal = ref.read( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()) - .launchUrlExternal, - ), - ); + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: url); - if (launchExternal) { - await ui_helper.launchUrlFeedback(context, Uri.parse(href)); - } else { - await ref - .read(tabRepositoryProvider.notifier) - .addTab(url: url); - - if (context.mounted) { - context.go(BrowserRoute().location); - } + if (context.mounted) { + context.go(BrowserRoute().location); } } } diff --git a/app/lib/features/chat_archive/presentation/screens/search.dart b/app/lib/features/chat_archive/presentation/screens/search.dart index d737c8b1..f271d795 100644 --- a/app/lib/features/chat_archive/presentation/screens/search.dart +++ b/app/lib/features/chat_archive/presentation/screens/search.dart @@ -10,8 +10,7 @@ import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/extensions/date_time.dart'; import 'package:lensai/features/chat_archive/domain/entities/chat_entity.dart'; import 'package:lensai/features/chat_archive/domain/repositories/search.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; +import 'package:lensai/features/user/domain/repositories/settings.dart'; import 'package:lensai/presentation/hooks/listenable_callback.dart'; import 'package:lensai/presentation/widgets/failure_widget.dart'; @@ -22,9 +21,7 @@ class ChatArchiveSearchScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final resultsAsync = ref.watch(chatArchiveSearchRepositoryProvider); final incognitoEnabled = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode, - ), + settingsRepositoryProvider.select((value) => value.incognitoMode), ); final textEditingController = useTextEditingController(); diff --git a/app/lib/features/geckoview/domain/entities/tab_state.dart b/app/lib/features/geckoview/domain/entities/tab_state.dart index 8dbcff85..066c8321 100644 --- a/app/lib/features/geckoview/domain/entities/tab_state.dart +++ b/app/lib/features/geckoview/domain/entities/tab_state.dart @@ -12,16 +12,14 @@ import 'package:lensai/features/geckoview/domain/entities/security_state.dart'; part 'tab_state.g.dart'; @CopyWith() -class TabState with FastEquatable implements WebPageInfo { +class TabState extends WebPageInfo with FastEquatable { @CopyWithField(immutable: true) final String id; final String? contextId; @override - final Uri url; - @override - final String title; + String get title => super.title!; final EquatableImage? icon; @@ -50,8 +48,8 @@ class TabState with FastEquatable implements WebPageInfo { TabState({ required this.id, required this.contextId, - required this.url, - required this.title, + required super.url, + required String title, required this.icon, required this.thumbnail, required this.progress, @@ -62,7 +60,7 @@ class TabState with FastEquatable implements WebPageInfo { required this.historyState, required this.readerableState, required this.findResultState, - }); + }) : super(title: title); factory TabState.$default(String tabId) => TabState( id: tabId, diff --git a/app/lib/features/geckoview/domain/providers/tab_state.dart b/app/lib/features/geckoview/domain/providers/tab_state.dart index 5f8dcdf1..62f08597 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.dart @@ -36,8 +36,8 @@ class TabStates extends _$TabStates { ); } - Future _onIconChange(IconEvent event) async { - final IconEvent(:tabId, :bytes) = event; + Future _onIconChange(IconChangeEvent event) async { + final IconChangeEvent(:tabId, :bytes) = event; final image = (bytes != null) ? (await tryDecodeImage(bytes) @@ -131,7 +131,7 @@ class TabStates extends _$TabStates { await _lock.synchronized(() => _onTabContentStateChange(event)); }, ), - eventService.iconEvents.listen( + eventService.iconChangeEvents.listen( (event) async { await _lock.synchronized(() => _onIconChange(event)); }, diff --git a/app/lib/features/geckoview/domain/providers/tab_state.g.dart b/app/lib/features/geckoview/domain/providers/tab_state.g.dart index eaca6134..90578176 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.g.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.g.dart @@ -173,7 +173,7 @@ final selectedTabStateProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef SelectedTabStateRef = AutoDisposeProviderRef; -String _$tabStatesHash() => r'dcd293279a901eeaab7728979e39e5ca3bee8c66'; +String _$tabStatesHash() => r'77f07f330dfac8dc8d28d18737203c7c122f3b74'; /// See also [TabStates]. @ProviderFor(TabStates) diff --git a/app/lib/features/geckoview/features/browser/domain/providers.dart b/app/lib/features/geckoview/features/browser/domain/providers.dart index 0096a336..1bc6dd9e 100644 --- a/app/lib/features/geckoview/features/browser/domain/providers.dart +++ b/app/lib/features/geckoview/features/browser/domain/providers.dart @@ -2,10 +2,12 @@ import 'dart:async'; +import 'package:lensai/data/models/equatable_iterable.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart'; import 'package:lensai/features/bangs/domain/repositories/data.dart'; import 'package:lensai/features/geckoview/domain/providers/tab_list.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:lensai/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; import 'package:lensai/features/kagi/data/entities/modes.dart'; import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -97,3 +99,34 @@ List availableTabIds( return containerTabs?.where((tabId) => tabStates.contains(tabId)).toList() ?? []; } + +@Riverpod() +List seamlessFilteredTabs( + Ref ref, + String? containerId, +) { + final tabSearchResults = ref + .watch( + tabSearchRepositoryProvider.select( + (value) => EquatableCollection( + value.valueOrNull?.map((tab) => tab.id).toList(), + immutable: true, + ), + ), + ) + .collection; + + final availableTabs = ref + .watch( + availableTabIdsProvider(containerId).select( + (value) => EquatableCollection(value, immutable: true), + ), + ) + .collection; + + if (tabSearchResults == null) { + return availableTabs; + } + + return tabSearchResults.where((tab) => availableTabs.contains(tab)).toList(); +} diff --git a/app/lib/features/geckoview/features/browser/domain/providers.g.dart b/app/lib/features/geckoview/features/browser/domain/providers.g.dart index bc98a966..0d19b141 100644 --- a/app/lib/features/geckoview/features/browser/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/browser/domain/providers.g.dart @@ -288,6 +288,140 @@ class _AvailableTabIdsProviderElement String? get containerId => (origin as AvailableTabIdsProvider).containerId; } +String _$seamlessFilteredTabsHash() => + r'1abfb0e9ae834cdde5ab64b6174f24f45f36a272'; + +/// See also [seamlessFilteredTabs]. +@ProviderFor(seamlessFilteredTabs) +const seamlessFilteredTabsProvider = SeamlessFilteredTabsFamily(); + +/// See also [seamlessFilteredTabs]. +class SeamlessFilteredTabsFamily extends Family> { + /// See also [seamlessFilteredTabs]. + const SeamlessFilteredTabsFamily(); + + /// See also [seamlessFilteredTabs]. + SeamlessFilteredTabsProvider call( + String? containerId, + ) { + return SeamlessFilteredTabsProvider( + containerId, + ); + } + + @override + SeamlessFilteredTabsProvider getProviderOverride( + covariant SeamlessFilteredTabsProvider provider, + ) { + return call( + provider.containerId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'seamlessFilteredTabsProvider'; +} + +/// See also [seamlessFilteredTabs]. +class SeamlessFilteredTabsProvider extends AutoDisposeProvider> { + /// See also [seamlessFilteredTabs]. + SeamlessFilteredTabsProvider( + String? containerId, + ) : this._internal( + (ref) => seamlessFilteredTabs( + ref as SeamlessFilteredTabsRef, + containerId, + ), + from: seamlessFilteredTabsProvider, + name: r'seamlessFilteredTabsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$seamlessFilteredTabsHash, + dependencies: SeamlessFilteredTabsFamily._dependencies, + allTransitiveDependencies: + SeamlessFilteredTabsFamily._allTransitiveDependencies, + containerId: containerId, + ); + + SeamlessFilteredTabsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.containerId, + }) : super.internal(); + + final String? containerId; + + @override + Override overrideWith( + List Function(SeamlessFilteredTabsRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: SeamlessFilteredTabsProvider._internal( + (ref) => create(ref as SeamlessFilteredTabsRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + containerId: containerId, + ), + ); + } + + @override + AutoDisposeProviderElement> createElement() { + return _SeamlessFilteredTabsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SeamlessFilteredTabsProvider && + other.containerId == containerId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, containerId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SeamlessFilteredTabsRef on AutoDisposeProviderRef> { + /// The parameter `containerId` of this provider. + String? get containerId; +} + +class _SeamlessFilteredTabsProviderElement + extends AutoDisposeProviderElement> + with SeamlessFilteredTabsRef { + _SeamlessFilteredTabsProviderElement(super.provider); + + @override + String? get containerId => + (origin as SeamlessFilteredTabsProvider).containerId; +} + String _$selectedBangTriggerHash() => r'b0e12bc95e93d50d04f1c658230cea9f15ff0385'; diff --git a/app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart b/app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart index 8434d25a..fcb9b66a 100644 --- a/app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart +++ b/app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart @@ -6,7 +6,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/data/models/web_page_info.dart'; -import 'package:lensai/features/bangs/data/models/bang_data.dart'; import 'package:lensai/features/bangs/domain/providers/bangs.dart'; import 'package:lensai/features/bangs/presentation/widgets/site_search.dart'; import 'package:lensai/features/geckoview/domain/providers.dart'; @@ -14,13 +13,11 @@ import 'package:lensai/features/geckoview/domain/providers/tab_session.dart'; import 'package:lensai/features/geckoview/domain/repositories/tab.dart'; import 'package:lensai/features/kagi/data/entities/modes.dart'; import 'package:lensai/features/kagi/utils/url_builder.dart' as uri_builder; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; +import 'package:lensai/features/user/domain/repositories/settings.dart'; import 'package:lensai/presentation/widgets/failure_widget.dart'; import 'package:lensai/presentation/widgets/website_title_tile.dart'; import 'package:lensai/utils/ui_helper.dart' as ui_helper; -import 'package:lensai/utils/uri_parser.dart' as uri_parser; import 'package:share_plus/share_plus.dart'; class WebPageDialog extends HookConsumerWidget { @@ -39,9 +36,7 @@ class WebPageDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final incognitoEnabled = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode, - ), + settingsRepositoryProvider.select((value) => value.incognitoMode), ); final availableBangsAsync = ref.watch( @@ -57,8 +52,9 @@ class WebPageDialog extends HookConsumerWidget { final availableBangCount = availableBangsAsync.valueOrNull?.length; final formKey = useMemoized(() => GlobalKey()); - final urlTextController = useTextEditingController(text: url.toString()); - final urlTextFocusNode = useFocusNode(); + final addressTextController = + useTextEditingController(text: url.toString()); + final addressTextFocusNode = useFocusNode(); return Stack( children: [ @@ -83,46 +79,35 @@ class WebPageDialog extends HookConsumerWidget { child: Form( key: formKey, child: TextFormField( - controller: urlTextController, - focusNode: urlTextFocusNode, + controller: addressTextController, + focusNode: addressTextFocusNode, enableIMEPersonalizedLearning: !incognitoEnabled, - decoration: InputDecoration( - labelText: 'URL', - suffixIcon: IconButton( - onPressed: () async { - if (formKey.currentState?.validate() ?? false) { - await ref - .read(tabSessionProvider(tabId: null).notifier) - .loadUrl( - url: uri_parser.tryParseUrl( - urlTextController.text, - eagerParsing: true, - )!, - ); - - onDismiss?.call(); - } - }, - icon: const Icon(Icons.send), - ), + decoration: const InputDecoration( + labelText: 'Address', ), validator: (value) { - if (uri_parser.tryParseUrl(value, eagerParsing: true) != - null) { + if (value != null && Uri.tryParse(value) != null) { return null; } - return 'Invalid URL'; + return 'Invalid Address'; }, onTap: () { - if (!urlTextFocusNode.hasFocus) { + if (!addressTextFocusNode.hasFocus) { // Select all text when the field is tapped - urlTextController.selection = TextSelection( + addressTextController.selection = TextSelection( baseOffset: 0, - extentOffset: urlTextController.text.length, + extentOffset: addressTextController.text.length, ); } }, + onFieldSubmitted: (value) async { + await ref + .read(tabSessionProvider(tabId: null).notifier) + .loadUrl(url: Uri.tryParse(value)!); + + onDismiss?.call(); + }, ), ), ), @@ -148,12 +133,12 @@ class WebPageDialog extends HookConsumerWidget { loading: () => SiteSearch( domain: url.host, availableBangs: [ - BangData( - websiteName: 'websiteName', - domain: 'domain', - trigger: 'trigger', - urlTemplate: 'urlTemplate', - ), + // BangData( + // websiteName: 'websiteName', + // domain: 'domain', + // trigger: 'trigger', + // urlTemplate: 'urlTemplate', + // ), ], ), ), @@ -181,7 +166,7 @@ class WebPageDialog extends HookConsumerWidget { ), ListTile( leading: const Icon(MdiIcons.tabPlus), - title: const Text('Open in new tab'), + title: const Text('Clone tab'), onTap: () async { await ref.read(tabRepositoryProvider.notifier).addTab(url: url); diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart index 42b39374..fcc8dd5e 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; @@ -30,13 +28,10 @@ import 'package:lensai/features/geckoview/features/find_in_page/presentation/wid import 'package:lensai/features/geckoview/features/readerview/presentation/widgets/reader_appearance_button.dart'; import 'package:lensai/features/geckoview/features/readerview/presentation/widgets/reader_button.dart'; import 'package:lensai/features/kagi/data/entities/modes.dart'; -import 'package:lensai/features/kagi/data/services/session.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; +import 'package:lensai/features/user/domain/repositories/settings.dart'; import 'package:lensai/presentation/hooks/overlay_portal_controller.dart'; import 'package:lensai/utils/ui_helper.dart' as ui_helper; import 'package:share_plus/share_plus.dart'; -import 'package:speech_to_text_google_dialog/speech_to_text_google_dialog.dart'; class BrowserScreen extends HookConsumerWidget { const BrowserScreen({super.key}); @@ -54,26 +49,6 @@ class BrowserScreen extends HookConsumerWidget { final displayedSheet = ref.watch(bottomSheetControllerProvider); final displayedOverlayDialog = ref.watch(overlayDialogControllerProvider); - final showEarlyAccessFeatures = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()) - .showEarlyAccessFeatures, - ), - ); - - final quickAction = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).quickAction, - ), - ); - - final quickActionVoice = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()) - .quickActionVoiceInput, - ), - ); - final selectedTabId = ref.watch(selectedTabStateProvider.select((value) => value?.id)); @@ -97,26 +72,23 @@ class BrowserScreen extends HookConsumerWidget { ); ref.listen( - settingsRepositoryProvider - .select((value) => value.valueOrNull?.enableJavascript), + settingsRepositoryProvider.select((value) => value.enableJavascript), (previous, next) async { - if (next != null) { - await ref.read(engineSettingsServiceProvider).javaScriptEnabled(next); - } + await ref.read(engineSettingsServiceProvider).javaScriptEnabled(next); }, ); - ref.listen( - settingsRepositoryProvider - .select((value) => value.valueOrNull?.kagiSession), - (previous, next) async { - if (next != null && next.isNotEmpty) { - await ref - .read(kagiSessionServiceProvider.notifier) - .setKagiSession(next); - } - }, - ); + // ref.listen( + // settingsRepositoryProvider + // .select((value) => value.valueOrNull?.kagiSession), + // (previous, next) async { + // if (next != null && next.isNotEmpty) { + // await ref + // .read(kagiSessionServiceProvider.notifier) + // .setKagiSession(next); + // } + // }, + // ); return PopScope( //We need this for BackButtonListener to work downstream @@ -217,78 +189,78 @@ class BrowserScreen extends HookConsumerWidget { }, icon: Icon(KagiTool.summarizer.icon), ), - if (showEarlyAccessFeatures) - IconButton( - color: (activeTool == KagiTool.assistant) - ? Theme.of(context).colorScheme.primary - : null, - onPressed: () { - ref - .read( - createTabStreamProvider.notifier, - ) - .createTab( - CreateTabSheet( - preferredTool: KagiTool.assistant, - ), - ); - }, - icon: Icon(KagiTool.assistant.icon), - ), + // if (showEarlyAccessFeatures) + // IconButton( + // color: (activeTool == KagiTool.assistant) + // ? Theme.of(context).colorScheme.primary + // : null, + // onPressed: () { + // ref + // .read( + // createTabStreamProvider.notifier, + // ) + // .createTab( + // CreateTabSheet( + // preferredTool: KagiTool.assistant, + // ), + // ); + // }, + // icon: Icon(KagiTool.assistant.icon), + // ), ], ); }, ), actions: [ if (selectedTabId != null) ReaderButton(), - if (quickAction != null) - InkWell( - onTap: () async { - var tab = CreateTabSheet( - preferredTool: quickAction, - ); + // if (quickAction != null) + // InkWell( + // onTap: () async { + // var tab = CreateTabSheet( + // preferredTool: quickAction, + // ); - if (quickActionVoice) { - final completer = Completer(); + // if (quickActionVoice) { + // final completer = Completer(); - final isServiceAvailable = - await SpeechToTextGoogleDialog.getInstance() - .showGoogleDialog( - onTextReceived: (data) { - completer.complete(data.toString()); - }, - // locale: "en-US", - ); + // final isServiceAvailable = + // await SpeechToTextGoogleDialog.getInstance() + // .showGoogleDialog( + // onTextReceived: (data) { + // completer.complete(data.toString()); + // }, + // // locale: "en-US", + // ); - if (!isServiceAvailable) { - if (context.mounted) { - ui_helper.showErrorMessage( - context, - 'Service is not available', - ); - } - } + // if (!isServiceAvailable) { + // if (context.mounted) { + // ui_helper.showErrorMessage( + // context, + // 'Service is not available', + // ); + // } + // } - tab = CreateTabSheet( - preferredTool: quickAction, - content: await completer.future, - ); - } + // tab = CreateTabSheet( + // preferredTool: quickAction, + // content: await completer.future, + // ); + // } - ref - .read(createTabStreamProvider.notifier) - .createTab(tab); - }, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 15.0, - horizontal: 8.0, - ), - child: Icon( - quickActionVoice ? Icons.mic : quickAction.icon, - ), - ), - ), + // ref + // .read(createTabStreamProvider.notifier) + // .createTab(tab); + // }, + // child: Padding( + // padding: const EdgeInsets.symmetric( + // vertical: 15.0, + // horizontal: 8.0, + // ), + // child: Icon( + // quickActionVoice ? Icons.mic : quickAction.icon, + // ), + // ), + // ), TabsActionButton( isActive: displayedSheet is ViewTabsSheet, onTap: () { @@ -394,8 +366,7 @@ class BrowserScreen extends HookConsumerWidget { ), MenuItemButton( onPressed: () async { - await ref - .read(addonServiceProvider) + await addonService .startAddonManagerActivity(); }, leadingIcon: const Icon(MdiIcons.puzzleEdit), diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_view.dart index 22e2b14c..c8f7acc8 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_view.dart @@ -8,6 +8,7 @@ import 'package:lensai/features/geckoview/domain/providers.dart'; import 'package:lensai/features/geckoview/domain/providers/tab_state.dart'; import 'package:lensai/features/geckoview/domain/providers/web_extensions_state.dart'; import 'package:lensai/features/geckoview/domain/repositories/tab.dart'; +import 'package:lensai/features/user/domain/repositories/cache.dart'; class BrowserView extends StatefulHookConsumerWidget { final Duration screenshotPeriod; @@ -101,6 +102,11 @@ class _BrowserViewState extends ConsumerState webExtensionsStateProvider(WebExtensionActionType.page), (previous, next) {}, ); + + ref.listenManual( + cacheRepositoryProvider, + (previous, next) {}, + ); } @override diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/landing/action.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/landing/action.dart index c9966737..346681ec 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/landing/action.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/landing/action.dart @@ -4,17 +4,19 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/features/geckoview/features/browser/presentation/widgets/error_container.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; class LandingAction extends HookConsumerWidget { const LandingAction({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final sessionTokenAvailable = ref.watch( - settingsRepositoryProvider.select( - (value) => value.valueOrNull?.kagiSession?.isNotEmpty ?? false, - ), - ); + //TODO: REWORK! + final sessionTokenAvailable = false; + + // ref.watch( + // settingsRepositoryProvider.select( + // (value) => value.valueOrNull?.kagiSession?.isNotEmpty ?? false, + // ), + // ); return Visibility( visible: !sessionTokenAvailable, diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/create_tab.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/create_tab.dart index 2c4bd774..864edb61 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/create_tab.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/create_tab.dart @@ -4,11 +4,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/features/geckoview/features/browser/domain/entities/sheet.dart'; import 'package:lensai/features/kagi/data/entities/modes.dart'; -import 'package:lensai/features/kagi/presentation/tabs/assistant_tab.dart'; import 'package:lensai/features/kagi/presentation/tabs/search_tab.dart'; import 'package:lensai/features/kagi/presentation/tabs/summarize_tab.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; import 'package:lensai/presentation/hooks/sync_page_tab.dart'; @@ -27,13 +24,6 @@ class CreateTabSheetWidget extends HookConsumerWidget { }); @override Widget build(BuildContext context, WidgetRef ref) { - final showEarlyAccessFeatures = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()) - .showEarlyAccessFeatures, - ), - ); - final sharedContent = useMemoized( () => (parameter.content != null) ? SharedContent.parse(parameter.content!) @@ -45,20 +35,22 @@ class CreateTabSheetWidget extends HookConsumerWidget { () => parameter.preferredTool?.index ?? switch (sharedContent) { - SharedText(text: final text) => - (showEarlyAccessFeatures && text.length > 25) - ? KagiTool.assistant.index - : KagiTool.search.index, + SharedText(text: final text) => KagiTool.search.index + // (showEarlyAccessFeatures && text.length > 25) + // ? KagiTool.assistant.index + // : KagiTool.search.index, + , SharedUrl() => KagiTool.summarizer.index, - null => showEarlyAccessFeatures - ? KagiTool.assistant.index - : KagiTool.search.index, + null => KagiTool.search.index + // showEarlyAccessFeatures + // ? KagiTool.assistant.index + // : KagiTool.search.index, }, [sharedContent], ); final tabController = useTabController( - initialLength: KagiTool.values.length - (showEarlyAccessFeatures ? 0 : 1), + initialLength: KagiTool.values.length - 1, initialIndex: initialIndex, ); final pageController = usePageController(initialPage: tabController.index); @@ -95,11 +87,11 @@ class CreateTabSheetWidget extends HookConsumerWidget { icon: Icon(KagiTool.summarizer.icon), text: 'Summarize', ), - if (showEarlyAccessFeatures) - Tab( - icon: Icon(KagiTool.assistant.icon), - text: 'Assistant', - ), + // if (showEarlyAccessFeatures) + // Tab( + // icon: Icon(KagiTool.assistant.icon), + // text: 'Assistant', + // ), ], ), Padding( @@ -115,11 +107,11 @@ class CreateTabSheetWidget extends HookConsumerWidget { sharedContent: sharedContent, onSubmit: onSubmit, ), - if (showEarlyAccessFeatures) - AssistantTab( - sharedContent: sharedContent, - onSubmit: onSubmit, - ), + // if (showEarlyAccessFeatures) + // AssistantTab( + // sharedContent: sharedContent, + // onSubmit: onSubmit, + // ), ], ), ), diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart index 8e650848..3fcc68bb 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart @@ -10,48 +10,65 @@ import 'package:lensai/features/geckoview/domain/providers/tab_state.dart'; import 'package:lensai/features/geckoview/domain/repositories/tab.dart'; import 'package:lensai/features/geckoview/features/browser/domain/providers.dart'; import 'package:lensai/features/geckoview/features/browser/presentation/dialogs/tab_action.dart'; +import 'package:lensai/features/geckoview/features/browser/presentation/widgets/speech_to_text_button.dart'; import 'package:lensai/features/geckoview/features/browser/presentation/widgets/tab_preview.dart'; import 'package:lensai/features/geckoview/features/controllers/overlay_dialog.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:lensai/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; import 'package:lensai/features/geckoview/features/tabs/presentation/widgets/container_chips.dart'; +import 'package:lensai/presentation/hooks/listenable_callback.dart'; import 'package:reorderable_grid/reorderable_grid.dart'; -class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate { - static const _headerSize = 104.0; - +class _Tab extends HookConsumerWidget { final VoidCallback onClose; - _SliverHeaderDelagate({required this.onClose}); + const _Tab({required this.onClose}); @override - Widget build( - BuildContext context, - double shrinkOffset, - bool overlapsContent, - ) { + Widget build(BuildContext context, WidgetRef ref) { + final searchMode = useState(false); + final searchTextFocus = useFocusNode(); + final searchTextController = useTextEditingController(); + + final hasSearchText = useListenableSelector( + searchTextController, + () => searchTextController.text.isNotEmpty, + ); + + useListenableCallback( + searchTextController, + () async { + await ref + .read(tabSearchRepositoryProvider.notifier) + .addQuery(searchTextController.text); + }, + ); + return Material( - child: Consumer( - builder: (context, ref, child) { - //Fix layout issue https://github.com/flutter/flutter/issues/78748#issuecomment-1194680555 - return Align( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ + //Fix layout issue https://github.com/flutter/flutter/issues/78748#issuecomment-1194680555 + child: Align( + child: Padding( + padding: const EdgeInsets.only( + right: 8.0, + left: 8.0, + top: 8.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!searchMode.value) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ TextButton.icon( - onPressed: () async { - await ref - .read(tabRepositoryProvider.notifier) - .addTab(url: Uri.https('kagi.com')); - - onClose(); + icon: const Icon(Icons.search), + label: const Text('Search'), + onPressed: () { + searchMode.value = true; + searchTextFocus.requestFocus(); }, - icon: const Icon(Icons.add), - label: const Text('New Tab'), ), TextButton.icon( onPressed: () async { @@ -66,46 +83,92 @@ class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate { label: const Text('Close All'), ), ], - ), - Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Consumer( - builder: (context, ref, child) { - final selectedContainer = ref.watch( - selectedContainerDataProvider - .select((value) => value.valueOrNull), - ); - - return ContainerChips( - selectedContainer: selectedContainer, - onSelected: (container) { - ref - .read(selectedContainerProvider.notifier) - .setContainerId(container.id); - }, - onDeleted: (container) async { - ref - .read(selectedContainerProvider.notifier) - .clearContainer(); - }, - ); - }, + ) + else + TextField( + controller: searchTextController, + focusNode: searchTextFocus, + // enableIMEPersonalizedLearning: !incognitoEnabled, + decoration: InputDecoration( + // border: InputBorder.none, + prefixIcon: const Icon(Icons.search), + hintText: 'Search inside tabs...', + floatingLabelBehavior: FloatingLabelBehavior.always, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!hasSearchText) + SpeechToTextButton( + onTextReceived: (data) { + searchTextController.text = data.toString(); + }, + ), + IconButton( + onPressed: () { + searchTextController.clear(); + searchMode.value = false; + }, + icon: const Icon(Icons.clear), + ), + ], + ), ), ), - const SizedBox(height: 8), - ], - ), - ); - }, + Padding( + padding: const EdgeInsets.only(left: 8.0, top: 12), + child: Consumer( + builder: (context, ref, child) { + final selectedContainer = ref.watch( + selectedContainerDataProvider + .select((value) => value.valueOrNull), + ); + + return ContainerChips( + selectedContainer: selectedContainer, + onSelected: (container) { + ref + .read(selectedContainerProvider.notifier) + .setContainerId(container.id); + }, + onDeleted: (container) async { + ref + .read(selectedContainerProvider.notifier) + .clearContainer(); + }, + ); + }, + ), + ), + const SizedBox(height: 8), + ], + ), + ), ), ); } +} + +class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate { + static const headerSize = 124.0; + + final VoidCallback onClose; + + _SliverHeaderDelagate({required this.onClose}); @override - double get minExtent => _headerSize; + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + return _Tab(onClose: onClose); + } @override - double get maxExtent => _headerSize; + double get minExtent => headerSize; + + @override + double get maxExtent => headerSize; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => @@ -143,165 +206,196 @@ class ViewTabsSheetWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return CustomScrollView( - controller: sheetScrollController, - slivers: [ - SliverPersistentHeader( - pinned: true, - delegate: _SliverHeaderDelagate(onClose: onClose), - ), - HookConsumer( - builder: (context, ref, child) { - final container = ref.watch(selectedContainerProvider); - final availableTabs = ref - .watch( - availableTabIdsProvider(container).select( - (value) => EquatableCollection(value, immutable: true), - ), - ) - .collection; - final activeTab = ref.watch(selectedTabProvider); + return Stack( + alignment: Alignment.bottomRight, + children: [ + CustomScrollView( + controller: sheetScrollController, + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: _SliverHeaderDelagate(onClose: onClose), + ), + HookConsumer( + builder: (context, ref, child) { + final container = ref.watch(selectedContainerProvider); - final itemHeight = useMemoized( - () => _calculateItemHeight( - screenWidth: MediaQuery.of(context).size.width, - childAspectRatio: 0.75, - horizontalPadding: 4.0, - mainAxisSpacing: 8.0, - crossAxisSpacing: 8.0, - crossAxisCount: 2, - ), - [MediaQuery.of(context).size.width], - ); - - useEffect( - () { - final index = - availableTabs.indexWhere((webView) => webView == activeTab); - - if (index > -1) { - final offset = (index ~/ 2) * itemHeight; - - if (offset != sheetScrollController.offset) { - unawaited( - sheetScrollController.animateTo( - offset, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, + final filteredTabs = ref + .watch( + seamlessFilteredTabsProvider(container).select( + (value) => EquatableCollection(value, immutable: true), ), - ); - } - } + ) + .collection; - return null; - }, - [availableTabs, activeTab], - ); + final activeTab = ref.watch(selectedTabProvider); - final tabs = useMemoized( - () => availableTabs - .mapIndexed( - (index, tabId) => ReorderableGridDelayedDragStartListener( - key: ValueKey(tabId), - index: index, - child: Consumer( - builder: (context, ref, child) { - final tab = ref.watch(tabStateProvider(tabId)); - return (tab != null) - ? TabPreview( - tab: tab, - isActive: tabId == activeTab, - onTap: () async { - if (tabId != activeTab) { - //Close first to avoid rebuilds - onClose(); - await ref - .read( - tabRepositoryProvider.notifier, - ) - .selectTab(tab.id); - } else { - onClose(); - } - }, - onDoubleTap: () { - ref - .read( - overlayDialogControllerProvider - .notifier, - ) - .show( - TabActionDialog( - initialTab: tab, - onDismiss: ref + final itemHeight = useMemoized( + () => _calculateItemHeight( + screenWidth: MediaQuery.of(context).size.width, + childAspectRatio: 0.75, + horizontalPadding: 4.0, + mainAxisSpacing: 8.0, + crossAxisSpacing: 8.0, + crossAxisCount: 2, + ), + [MediaQuery.of(context).size.width], + ); + + useEffect( + () { + final index = filteredTabs + .indexWhere((webView) => webView == activeTab); + + if (index > -1) { + final offset = (index ~/ 2) * itemHeight; + + if (offset != sheetScrollController.offset) { + unawaited( + sheetScrollController.animateTo( + offset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ), + ); + } + } + + return null; + }, + [filteredTabs, activeTab], + ); + + final tabs = useMemoized( + () { + return filteredTabs + .mapIndexed( + (index, tabId) => + ReorderableGridDelayedDragStartListener( + key: ValueKey(tabId), + index: index, + child: Consumer( + builder: (context, ref, child) { + final tab = ref.watch(tabStateProvider(tabId)); + return (tab != null) + ? TabPreview( + tab: tab, + isActive: tabId == activeTab, + onTap: () async { + if (tabId != activeTab) { + //Close first to avoid rebuilds + onClose(); + await ref .read( - overlayDialogControllerProvider + tabRepositoryProvider .notifier, ) - .dismiss, - ), - ); - }, - onDelete: () async { - await ref - .read( - tabRepositoryProvider.notifier, - ) - .closeTab(tab.id); - }, - ) - : const SizedBox.shrink(); - }, - ), + .selectTab(tab.id); + } else { + onClose(); + } + }, + onDoubleTap: () { + ref + .read( + overlayDialogControllerProvider + .notifier, + ) + .show( + TabActionDialog( + initialTab: tab, + onDismiss: ref + .read( + overlayDialogControllerProvider + .notifier, + ) + .dismiss, + ), + ); + }, + onDelete: () async { + await ref + .read( + tabRepositoryProvider.notifier, + ) + .closeTab(tab.id); + }, + ) + : const SizedBox.shrink(); + }, + ), + ), + ) + .toList(); + }, + [ + EquatableCollection(filteredTabs, immutable: true), + activeTab + ], + ); + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + sliver: SliverReorderableGrid( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + //Sync values for itemHeight calculation _calculateItemHeight + childAspectRatio: 0.75, + mainAxisSpacing: 8.0, + crossAxisSpacing: 8.0, + crossAxisCount: 2, ), - ) - .toList(), - [availableTabs, activeTab], - ); + itemCount: tabs.length, + itemBuilder: (context, index) => tabs[index], + onReorder: (oldIndex, newIndex) async { + final containerRepository = + ref.read(containerRepositoryProvider.notifier); - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - sliver: SliverReorderableGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - //Sync values for itemHeight calculation _calculateItemHeight - childAspectRatio: 0.75, - mainAxisSpacing: 8.0, - crossAxisSpacing: 8.0, - crossAxisCount: 2, - ), - itemCount: tabs.length, - itemBuilder: (context, index) => tabs[index], - onReorder: (oldIndex, newIndex) async { - final containerRepository = - ref.read(containerRepositoryProvider.notifier); + final tabId = filteredTabs[oldIndex]; + final containerId = await ref + .read(tabDataRepositoryProvider.notifier) + .containerTabId(tabId); - final tabId = availableTabs[oldIndex]; - final containerId = await ref - .read(tabDataRepositoryProvider.notifier) - .containerTabId(tabId); + final String key; + if (newIndex <= 0) { + key = await containerRepository + .getLeadingOrderKey(containerId); + } else if (newIndex >= filteredTabs.length - 1) { + key = await containerRepository + .getTrailingOrderKey(containerId); + } else { + final orderAfterIndex = newIndex; + key = await containerRepository.getOrderKeyAfterTab( + filteredTabs[orderAfterIndex], + containerId, + ); + } - final String key; - if (newIndex <= 0) { - key = await containerRepository - .getLeadingOrderKey(containerId); - } else if (newIndex >= availableTabs.length - 1) { - key = await containerRepository - .getTrailingOrderKey(containerId); - } else { - final orderAfterIndex = newIndex - 1; - key = await containerRepository.getOrderKeyAfterTab( - availableTabs[orderAfterIndex], - containerId, - ); - } + await ref + .read(tabDataRepositoryProvider.notifier) + .assignOrderKey(tabId, key); + }, + ), + ); + }, + ), + ], + ), + Padding( + padding: const EdgeInsets.only( + top: _SliverHeaderDelagate.headerSize + 4, + right: 4, + ), + child: FloatingActionButton.small( + onPressed: () async { + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: Uri.https('kagi.com')); - await ref - .read(tabDataRepositoryProvider.notifier) - .assignOrderKey(tabId, key); - }, - ), - ); - }, + onClose(); + }, + child: Icon(Icons.add), + ), ), ], ); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_preview.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_preview.dart index 8a903d54..4c04c1a3 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_preview.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_preview.dart @@ -91,9 +91,11 @@ class TabPreview extends StatelessWidget { ), child: SizedBox( width: double.infinity, - child: RawImage( - image: tab.thumbnail!.value, - fit: BoxFit.fitWidth, + child: RepaintBoundary( + child: RawImage( + image: tab.thumbnail!.value, + fit: BoxFit.fitWidth, + ), ), ), ), diff --git a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart index 0d11c6af..e212c62c 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart @@ -1,8 +1,6 @@ import 'dart:async'; -import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; -import 'package:lensai/core/logger.dart'; import 'package:lensai/features/geckoview/domain/entities/tab_state.dart'; import 'package:lensai/features/geckoview/features/tabs/data/database/database.dart'; import 'package:lensai/features/geckoview/features/tabs/data/models/tab_query_result.dart'; diff --git a/app/lib/features/geckoview/features/tabs/data/database/database.drift b/app/lib/features/geckoview/features/tabs/data/database/database.drift index 15c19f78..5c95881a 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/database.drift +++ b/app/lib/features/geckoview/features/tabs/data/database/database.drift @@ -138,6 +138,7 @@ queryTabsFullContent WITH TabQueryResult: 1.0 as full_weight -- Full content matches less important ) SELECT + t.id, highlight(tab_fts, 0, :beforeMatch, :afterMatch) AS title, highlight(tab_fts, 1, :beforeMatch, :afterMatch) AS url, snippet(tab_fts, 2, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS extracted_content, diff --git a/app/lib/features/geckoview/features/tabs/data/database/database.g.dart b/app/lib/features/geckoview/features/tabs/data/database/database.g.dart index d7418b35..b2b741c8 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/database.g.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/database.g.dart @@ -977,6 +977,7 @@ abstract class _$TabDatabase extends GeneratedDatabase { tab, tabFts, }).map((QueryRow row) => TabQueryResult( + id: row.read('id'), title: row.read('title'), url: row.read('url'), weightedRank: row.read('weighted_rank'), @@ -990,7 +991,7 @@ abstract class _$TabDatabase extends GeneratedDatabase { required int snippetLength, required String query}) { return customSelect( - 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, snippet(tab_fts, 2, ?1, ?2, ?3, ?4) AS extracted_content, snippet(tab_fts, 3, ?1, ?2, ?3, ?4) AS full_content,(bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight))AS weighted_rank FROM tab_fts(?5)AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights ORDER BY weighted_rank ASC, t.timestamp DESC', + 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT t.id, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, snippet(tab_fts, 2, ?1, ?2, ?3, ?4) AS extracted_content, snippet(tab_fts, 3, ?1, ?2, ?3, ?4) AS full_content,(bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight))AS weighted_rank FROM tab_fts(?5)AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights ORDER BY weighted_rank ASC, t.timestamp DESC', variables: [ Variable(beforeMatch), Variable(afterMatch), @@ -999,9 +1000,10 @@ abstract class _$TabDatabase extends GeneratedDatabase { Variable(query) ], readsFrom: { - tabFts, tab, + tabFts, }).map((QueryRow row) => TabQueryResult( + id: row.read('id'), title: row.read('title'), url: row.read('url'), extractedContent: row.read('extracted_content'), diff --git a/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart b/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart index 6ff663e1..a23dd50f 100644 --- a/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart +++ b/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart @@ -1,4 +1,6 @@ class TabQueryResult { + final String id; + final String title; final String url; @@ -8,6 +10,7 @@ class TabQueryResult { final double weightedRank; TabQueryResult({ + required this.id, required this.title, required this.url, this.extractedContent, diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart index 54c00f4b..39139ea8 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart @@ -30,13 +30,15 @@ class TabSearchRepository extends _$TabSearchRepository { ) .get(), ); + } else { + state = const AsyncValue.data(null); } } @override - Future> build() { + Future?> build() { _db = ref.watch(tabDatabaseProvider); - return Future.value([]); + return Future.value(); } } diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart index c10cd957..d0cc367a 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart @@ -7,12 +7,12 @@ part of 'tab_search.dart'; // ************************************************************************** String _$tabSearchRepositoryHash() => - r'211ca4d7e9a744b5f8a32970ba3a6df721fc05e1'; + r'9bd42f89bcc3a8d94add093076737dd57383bae2'; /// See also [TabSearchRepository]. @ProviderFor(TabSearchRepository) final tabSearchRepositoryProvider = AutoDisposeAsyncNotifierProvider< - TabSearchRepository, List>.internal( + TabSearchRepository, List?>.internal( TabSearchRepository.new, name: r'tabSearchRepositoryProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -22,6 +22,6 @@ final tabSearchRepositoryProvider = AutoDisposeAsyncNotifierProvider< allTransitiveDependencies: null, ); -typedef _$TabSearchRepository = AutoDisposeAsyncNotifier>; +typedef _$TabSearchRepository = AutoDisposeAsyncNotifier?>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/kagi/data/services/chat.dart b/app/lib/features/kagi/data/services/chat.dart index 439417df..2efe3188 100644 --- a/app/lib/features/kagi/data/services/chat.dart +++ b/app/lib/features/kagi/data/services/chat.dart @@ -1,9 +1,5 @@ -import 'dart:convert'; - import 'package:exceptions/exceptions.dart'; import 'package:http/http.dart' as http; -import 'package:lensai/core/http_error_handler.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'chat.g.dart'; @@ -18,17 +14,19 @@ class KagiChatService extends _$KagiChatService { } Future> downloadChat(Uri url) { - final kagiSession = - ref.read(settingsRepositoryProvider).valueOrNull?.kagiSession; + throw Exception('Not implemented !'); - return Result.fromAsync( - () async { - final response = await _client - .get(url.replace(queryParameters: {'token': kagiSession})); + // final kagiSession = + // ref.read(settingsRepositoryProvider).valueOrNull?.kagiSession; - return utf8.decode(response.bodyBytes); - }, - exceptionHandler: handleHttpError, - ); + // return Result.fromAsync( + // () async { + // final response = await _client + // .get(url.replace(queryParameters: {'token': kagiSession})); + + // return utf8.decode(response.bodyBytes); + // }, + // exceptionHandler: handleHttpError, + // ); } } diff --git a/app/lib/features/kagi/data/services/chat.g.dart b/app/lib/features/kagi/data/services/chat.g.dart index 5c3c7ff3..7edee631 100644 --- a/app/lib/features/kagi/data/services/chat.g.dart +++ b/app/lib/features/kagi/data/services/chat.g.dart @@ -6,7 +6,7 @@ part of 'chat.dart'; // RiverpodGenerator // ************************************************************************** -String _$kagiChatServiceHash() => r'fda3874c955812fa62d555afaca352a3776dd692'; +String _$kagiChatServiceHash() => r'a112b70b42fe9d650c9d4dc643bf965ddce7bfa6'; /// See also [KagiChatService]. @ProviderFor(KagiChatService) diff --git a/app/lib/features/kagi/presentation/tabs/assistant_tab.dart b/app/lib/features/kagi/presentation/tabs/assistant_tab.dart index 0c05d3e1..adf5f1c5 100644 --- a/app/lib/features/kagi/presentation/tabs/assistant_tab.dart +++ b/app/lib/features/kagi/presentation/tabs/assistant_tab.dart @@ -8,9 +8,8 @@ import 'package:lensai/features/geckoview/features/browser/presentation/widgets/ import 'package:lensai/features/geckoview/features/browser/presentation/widgets/speech_to_text_button.dart'; import 'package:lensai/features/kagi/data/entities/modes.dart'; import 'package:lensai/features/kagi/utils/url_builder.dart' as uri_builder; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; +import 'package:lensai/features/user/domain/repositories/settings.dart'; import 'package:lensai/presentation/hooks/sync_page_tab.dart'; class AssistantTab extends HookConsumerWidget { @@ -26,9 +25,7 @@ class AssistantTab extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final incognitoEnabled = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode, - ), + settingsRepositoryProvider.select((value) => value.incognitoMode), ); final researchVariant = ref.watch(activeResearchVariantProvider); diff --git a/app/lib/features/kagi/presentation/tabs/search_tab.dart b/app/lib/features/kagi/presentation/tabs/search_tab.dart index 8f0346f8..f1b3a76c 100644 --- a/app/lib/features/kagi/presentation/tabs/search_tab.dart +++ b/app/lib/features/kagi/presentation/tabs/search_tab.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/features/bangs/domain/providers/bangs.dart'; +import 'package:lensai/features/bangs/domain/providers/search.dart'; import 'package:lensai/features/bangs/domain/repositories/data.dart'; import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart'; import 'package:lensai/features/bangs/presentation/widgets/search_field.dart'; @@ -33,7 +34,8 @@ class SearchTab extends HookConsumerWidget { final selectedBang = ref .watch(selectedBangDataProvider().select((value) => value.valueOrNull)); final defaultSearchBang = ref.watch( - defaultSearchBangDataProvider.select((value) => value.valueOrNull)); + defaultSearchBangDataProvider.select((value) => value.valueOrNull), + ); final activeBang = selectedBang ?? defaultSearchBang; @@ -42,11 +44,11 @@ class SearchTab extends HookConsumerWidget { Future submitSearch() async { if (activeBang != null && (formKey.currentState?.validate() == true)) { - await ref - .read(bangDataRepositoryProvider.notifier) - .increaseFrequency(activeBang.trigger); + final searchUri = await ref.read( + triggerBangSearchProvider(activeBang, textController.text).future, + ); - onSubmit(activeBang.getUrl(textController.text)); + onSubmit(searchUri); } } diff --git a/app/lib/features/kagi/presentation/tabs/summarize_tab.dart b/app/lib/features/kagi/presentation/tabs/summarize_tab.dart index c794860d..8c53d33b 100644 --- a/app/lib/features/kagi/presentation/tabs/summarize_tab.dart +++ b/app/lib/features/kagi/presentation/tabs/summarize_tab.dart @@ -9,9 +9,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/features/geckoview/features/browser/presentation/widgets/sheets/create_tab.dart'; import 'package:lensai/features/kagi/data/entities/modes.dart'; import 'package:lensai/features/kagi/utils/url_builder.dart' as uri_builder; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; +import 'package:lensai/features/user/domain/repositories/settings.dart'; import 'package:lensai/presentation/widgets/website_title_tile.dart'; import 'package:lensai/utils/uri_parser.dart' as uri_parser; @@ -24,7 +23,7 @@ class _InputField extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final incognitoEnabled = ref.watch( settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode, + (value) => value.incognitoMode, ), ); diff --git a/app/lib/features/search/domain/fts_tokenizer.dart b/app/lib/features/search/domain/fts_tokenizer.dart index 9ba99ba4..cb4f7515 100644 --- a/app/lib/features/search/domain/fts_tokenizer.dart +++ b/app/lib/features/search/domain/fts_tokenizer.dart @@ -52,7 +52,10 @@ sealed class FtsQueryBuilder { //Merge short tokens _mergeShortBarewords(barewords, minTokenLength); - _tokens = barewords.take(tokenLimit).toList(); + _tokens = barewords + .where((bareword) => bareword.word.length >= minTokenLength) + .take(tokenLimit) + .toList(); } static void _mergeShortBarewords( @@ -62,7 +65,7 @@ sealed class FtsQueryBuilder { for (var i = 0; i < barewords.length; i++) { final bareword = barewords[i]; - if (i != 0) { + if (i > 0) { if (bareword.word.length < minTokenLength) { barewords[i] = barewords[i - 1].join(bareword); } diff --git a/app/lib/features/search/presentation/screens/search.dart b/app/lib/features/search/presentation/screens/search.dart index 502a0509..f0b42386 100644 --- a/app/lib/features/search/presentation/screens/search.dart +++ b/app/lib/features/search/presentation/screens/search.dart @@ -1,12 +1,16 @@ +import 'package:collection/collection.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'; import 'package:lensai/data/models/equatable_iterable.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart'; import 'package:lensai/features/bangs/domain/providers/bangs.dart'; +import 'package:lensai/features/bangs/domain/providers/search.dart'; import 'package:lensai/features/bangs/domain/repositories/data.dart'; import 'package:lensai/features/geckoview/domain/providers/tab_state.dart'; +import 'package:lensai/features/geckoview/domain/repositories/tab.dart'; import 'package:lensai/features/geckoview/features/browser/domain/providers.dart'; import 'package:lensai/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/providers/selected_container.dart'; @@ -75,6 +79,20 @@ class SearchScreen extends HookConsumerWidget { }, ); + Future submitSearch(String query) async { + if (activeBang != null && (formKey.currentState?.validate() == true)) { + final searchUri = await ref.read( + triggerBangSearchProvider(activeBang, query).future, + ); + + await ref.read(tabRepositoryProvider.notifier).addTab(url: searchUri); + + if (context.mounted) { + context.pop(); + } + } + } + final selectedContainer = useState(globalSelectedContainer); final availableTabs = ref @@ -111,7 +129,7 @@ class SearchScreen extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Site Search', + 'Search Provider', style: Theme.of(context).textTheme.labelSmall, ), BangChips( @@ -150,7 +168,7 @@ class SearchScreen extends HookConsumerWidget { ), HookConsumer( builder: (context, ref, child) { - final hasSearchText = useListenableSelector( + useListenableSelector( searchTextController, () => searchTextController.text.isNotEmpty, ); @@ -158,30 +176,65 @@ class SearchScreen extends HookConsumerWidget { final suggestions = useStream(ref.watch(searchSuggestionsProvider())); - return (!hasSearchText || !suggestions.hasData) - ? SliverList.builder( - itemCount: 5, - itemBuilder: (context, index) { - return ListTile( - leading: const Icon(Icons.history), - title: Text('Search History $index'), - ); - }, - ) - : SliverList.builder( - itemCount: suggestions.data!.length, - itemBuilder: (context, index) { - final suggestion = suggestions.data![index]; + final searchHistory = ref.watch(searchHistoryProvider); - return ListTile( - leading: const Icon(Icons.search), - title: Text(suggestion), - onLongPress: () { - searchTextController.text = suggestion; - }, - ); + final searchText = searchTextController.text; + + if ((!searchText.isNotEmpty || !suggestions.hasData) && + (searchHistory.value?.isNotEmpty ?? false)) { + final entries = searchHistory.value!; + + return SliverList.builder( + itemCount: entries.length, + itemBuilder: (context, index) { + final query = entries[index].searchQuery; + + return ListTile( + leading: const Icon(Icons.history), + title: Text(query), + onLongPress: () { + searchTextController.text = query; }, + onTap: () async { + await submitSearch(query); + }, + trailing: IconButton( + onPressed: () async { + await ref + .read(bangDataRepositoryProvider.notifier) + .removeSearchEntry(query); + }, + icon: const Icon(Icons.close), + ), ); + }, + ); + } + + final prioritizedSuggestions = [ + if (searchText.isNotEmpty) searchText, + if (suggestions.data != null) + ...suggestions.data! + .whereNot((suggestion) => suggestion == searchText), + ]; + + return SliverList.builder( + itemCount: prioritizedSuggestions.length, + itemBuilder: (context, index) { + final suggestion = prioritizedSuggestions[index]; + + return ListTile( + leading: const Icon(Icons.search), + title: Text(suggestion), + onLongPress: () { + searchTextController.text = suggestion; + }, + onTap: () async { + await submitSearch(suggestion); + }, + ); + }, + ); }, ), const SliverToBoxAdapter( @@ -220,6 +273,10 @@ class SearchScreen extends HookConsumerWidget { enabled: tabs.isLoading, child: tabs.when( data: (data) { + if (data == null) { + return const SizedBox.shrink(); + } + return SliverList.builder( itemCount: data.length, itemBuilder: (context, index) { @@ -282,10 +339,12 @@ class SearchScreen extends HookConsumerWidget { } return ListTile( - leading: RawImage( - image: tab.icon?.value, - height: 24, - width: 24, + leading: RepaintBoundary( + child: RawImage( + image: tab.icon?.value, + height: 24, + width: 24, + ), ), title: Text(tab.title), subtitle: Text( diff --git a/app/lib/features/search/presentation/widgets/bang_chips.dart b/app/lib/features/search/presentation/widgets/bang_chips.dart index b5680ff2..fd52b208 100644 --- a/app/lib/features/search/presentation/widgets/bang_chips.dart +++ b/app/lib/features/search/presentation/widgets/bang_chips.dart @@ -66,53 +66,55 @@ class BangChips extends HookConsumerWidget { ); return availableBangs.when( - data: (availableBangs) => SizedBox( - height: 48, - child: Row( - children: [ - if (activeBang != null || availableBangs.isNotEmpty) - Expanded( - child: SelectableChips( - itemId: (bang) => bang.trigger, - itemAvatar: (bang) => BangIcon(bang, iconSize: 20), - itemLabel: (bang) => Text(bang.websiteName), - availableItems: availableBangs, - selectedItem: activeBang, - onSelected: onSelected, - onDeleted: onDeleted, - ), - ) - else - Visibility( - visible: displayMenu, - child: Expanded( - child: Text( - "Press '>' to search Bangs.", - style: TextStyle( - color: Theme.of(context).hintColor, - fontStyle: FontStyle.italic, + data: (availableBangs) { + return SizedBox( + height: 48, + child: Row( + children: [ + if (activeBang != null || availableBangs.isNotEmpty) + Expanded( + child: SelectableChips( + itemId: (bang) => bang.trigger, + itemAvatar: (bang) => BangIcon(bang, iconSize: 20), + itemLabel: (bang) => Text(bang.websiteName), + availableItems: availableBangs, + selectedItem: activeBang, + onSelected: onSelected, + onDeleted: onDeleted, + ), + ) + else + Visibility( + visible: displayMenu, + child: Expanded( + child: Text( + "Press '>' to search Bangs.", + style: TextStyle( + color: Theme.of(context).hintColor, + fontStyle: FontStyle.italic, + ), ), ), ), - ), - if (displayMenu) - IconButton( - onPressed: () async { - final searchText = searchTextController?.text.trim(); + if (displayMenu) + IconButton( + onPressed: () async { + final searchText = searchTextController?.text.trim(); - await context.push( - BangSearchRoute( - searchText: (searchText?.isEmpty ?? true) - ? BangSearchRoute.emptySearchText - : searchText!, - ).location, - ); - }, - icon: const Icon(Icons.chevron_right), - ), - ], - ), - ), + await context.push( + BangSearchRoute( + searchText: (searchText?.isEmpty ?? true) + ? BangSearchRoute.emptySearchText + : searchText!, + ).location, + ); + }, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + ); + }, error: (error, stackTrace) => const SizedBox.shrink(), loading: () => const SizedBox( height: 48, diff --git a/app/lib/features/search/presentation/widgets/search_field.dart b/app/lib/features/search/presentation/widgets/search_field.dart index 36523c81..9ae9975a 100644 --- a/app/lib/features/search/presentation/widgets/search_field.dart +++ b/app/lib/features/search/presentation/widgets/search_field.dart @@ -4,8 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart'; import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart'; import 'package:lensai/features/geckoview/features/browser/presentation/widgets/speech_to_text_button.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; +import 'package:lensai/features/user/domain/repositories/settings.dart'; class SearchField extends HookConsumerWidget { final TextEditingController textEditingController; @@ -28,7 +27,7 @@ class SearchField extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final incognitoEnabled = ref.watch( settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode, + (value) => value.incognitoMode, ), ); diff --git a/app/lib/features/settings/data/repositories/settings_repository.dart b/app/lib/features/settings/data/repositories/settings_repository.dart deleted file mode 100644 index 806dd2c1..00000000 --- a/app/lib/features/settings/data/repositories/settings_repository.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/utils/preference_parser.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -part 'settings_repository.g.dart'; - -typedef UpdateSettingsFunc = Settings Function(Settings currentSettings); - -enum _StorageKeys { - kagiSession('kagi_session'), - showEarlyAccessFeatures('show_early_access_features'), - incognito('enabe_incognito'), - javascript('enable_js'), - launchExternal('enable_launch_external'), - blockHttpProtocol('block_http'), - themeMode('theme_mode'), - quickAction('enable_quick_action'), - quickActionVoiceInput('enable_quick_action_voice_input'), - enableReadability('enable_readability'); - - final String key; - - const _StorageKeys(this.key); -} - -@Riverpod(keepAlive: true) -class SettingsRepository extends _$SettingsRepository { - final FlutterSecureStorage _flutterSecureStorage; - final Future _sharedPreferences; - - SettingsRepository() - : _flutterSecureStorage = const FlutterSecureStorage(), - _sharedPreferences = SharedPreferences.getInstance(); - - Future updateSettings(UpdateSettingsFunc updateWithCurrent) async { - final sharedPreferences = await _sharedPreferences; - final oldSettings = state.value!; - final newSettings = updateWithCurrent(oldSettings); - - if (oldSettings != newSettings) { - if (oldSettings.kagiSession != newSettings.kagiSession) { - await _flutterSecureStorage.write( - key: _StorageKeys.kagiSession.key, - value: (newSettings.kagiSession?.isNotEmpty ?? false) - ? newSettings.kagiSession - : null, - ); - } - - if (newSettings.showEarlyAccessFeatures != - oldSettings.showEarlyAccessFeatures) { - await sharedPreferences.setBool( - _StorageKeys.showEarlyAccessFeatures.key, - newSettings.showEarlyAccessFeatures, - ); - } - - if (newSettings.incognitoMode != oldSettings.incognitoMode) { - await sharedPreferences.setBool( - _StorageKeys.incognito.key, - newSettings.incognitoMode, - ); - } - - if (newSettings.enableJavascript != oldSettings.enableJavascript) { - await sharedPreferences.setBool( - _StorageKeys.javascript.key, - newSettings.enableJavascript, - ); - } - - if (newSettings.launchUrlExternal != oldSettings.launchUrlExternal) { - await sharedPreferences.setBool( - _StorageKeys.launchExternal.key, - newSettings.launchUrlExternal, - ); - } - - if (newSettings.blockHttpProtocol != oldSettings.blockHttpProtocol) { - await sharedPreferences.setBool( - _StorageKeys.blockHttpProtocol.key, - newSettings.blockHttpProtocol, - ); - } - - if (newSettings.themeMode != oldSettings.themeMode) { - await sharedPreferences.setInt( - _StorageKeys.themeMode.key, - newSettings.themeMode.index, - ); - } - - if (newSettings.quickAction != oldSettings.quickAction) { - final index = newSettings.quickAction?.index; - if (index != null) { - await sharedPreferences.setInt( - _StorageKeys.quickAction.key, - index, - ); - } else { - await sharedPreferences.remove(_StorageKeys.quickAction.key); - } - } - - if (newSettings.quickActionVoiceInput != - oldSettings.quickActionVoiceInput) { - await sharedPreferences.setBool( - _StorageKeys.quickActionVoiceInput.key, - newSettings.quickActionVoiceInput, - ); - } - - if (newSettings.enableReadability != oldSettings.enableReadability) { - await sharedPreferences.setBool( - _StorageKeys.enableReadability.key, - newSettings.enableReadability, - ); - } - - ref.invalidateSelf(); - } - } - - @override - FutureOr build() async { - final sharedPreferences = await _sharedPreferences; - - return Settings.withDefaults( - kagiSession: - await _flutterSecureStorage.read(key: _StorageKeys.kagiSession.key), - showEarlyAccessFeatures: - sharedPreferences.getBool(_StorageKeys.showEarlyAccessFeatures.key), - incognitoMode: sharedPreferences.getBool(_StorageKeys.incognito.key), - enableJavascript: sharedPreferences.getBool(_StorageKeys.javascript.key), - launchUrlExternal: - sharedPreferences.getBool(_StorageKeys.launchExternal.key), - blockHttpProtocol: - sharedPreferences.getBool(_StorageKeys.blockHttpProtocol.key), - themeMode: - parseThemeMode(sharedPreferences.getInt(_StorageKeys.themeMode.key)), - quickAction: - parseKagiTool(sharedPreferences.getInt(_StorageKeys.quickAction.key)), - quickActionVoiceInput: - sharedPreferences.getBool(_StorageKeys.quickActionVoiceInput.key), - enableReadability: - sharedPreferences.getBool(_StorageKeys.enableReadability.key), - ); - } -} diff --git a/app/lib/features/settings/presentation/controllers/save_settings.dart b/app/lib/features/settings/presentation/controllers/save_settings.dart index 5a76ec57..b82d3c83 100644 --- a/app/lib/features/settings/presentation/controllers/save_settings.dart +++ b/app/lib/features/settings/presentation/controllers/save_settings.dart @@ -1,4 +1,4 @@ -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; +import 'package:lensai/features/user/domain/repositories/settings.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'save_settings.g.dart'; diff --git a/app/lib/features/settings/presentation/screens/settings.dart b/app/lib/features/settings/presentation/screens/settings.dart index 60adee19..d455deab 100644 --- a/app/lib/features/settings/presentation/screens/settings.dart +++ b/app/lib/features/settings/presentation/screens/settings.dart @@ -1,21 +1,16 @@ import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/features/bangs/data/models/bang.dart'; -import 'package:lensai/features/bangs/domain/providers/bangs.dart'; import 'package:lensai/features/bangs/domain/repositories/data.dart'; -import 'package:lensai/features/kagi/data/entities/modes.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; import 'package:lensai/features/settings/presentation/controllers/save_settings.dart'; import 'package:lensai/features/settings/presentation/widgets/bang_group_list_tile.dart'; import 'package:lensai/features/settings/presentation/widgets/custom_list_tile.dart'; -import 'package:lensai/features/settings/utils/session_link_extractor.dart'; -import 'package:lensai/presentation/hooks/listenable_callback.dart'; -import 'package:lensai/utils/ui_helper.dart' as ui_helper; +import 'package:lensai/features/user/data/models/settings.dart'; +import 'package:lensai/features/user/domain/providers/cache.dart'; +import 'package:lensai/features/user/domain/repositories/cache.dart'; +import 'package:lensai/features/user/domain/repositories/settings.dart'; class SettingsScreen extends HookConsumerWidget { const SettingsScreen({super.key}); @@ -44,40 +39,7 @@ class SettingsScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - final settings = ref.watch( - settingsRepositoryProvider.select( - (value) => value.valueOrNull ?? Settings.withDefaults(), - ), - ); - - final kagiSessionTextController = useTextEditingController( - text: settings.kagiSession, - ); - final hideSessionText = useState(true); - - useListenableCallback(kagiSessionTextController, () async { - var text = kagiSessionTextController.text; - if (Uri.tryParse(text) case final Uri uri) { - if (extractKagiSession(uri) case final String session) { - text = session; - } - } - - await ref.read(saveSettingsControllerProvider.notifier).save( - (currentSettings) => currentSettings.copyWith.kagiSession(text), - ); - }); - - ref.listen( - settingsRepositoryProvider.select( - (settings) => settings.valueOrNull?.kagiSession, - ), (previous, next) { - if (next != null && - next.isNotEmpty && - kagiSessionTextController.text != next) { - kagiSessionTextController.text = next; - } - }); + final settings = ref.watch(settingsRepositoryProvider); return Scaffold( appBar: AppBar(title: const Text('Settings')), @@ -87,67 +49,6 @@ class SettingsScreen extends HookConsumerWidget { return ListView( controller: controller, children: [ - _buildSection(theme, 'Kagi'), - Padding( - padding: const EdgeInsets.only( - left: 16, - right: 16, - bottom: 8, - ), - child: TextField( - enableIMEPersonalizedLearning: false, - autocorrect: false, - controller: kagiSessionTextController, - obscureText: hideSessionText.value, - decoration: InputDecoration( - label: const Text('Kagi Session Token'), - hintText: 'https://kagi.com/search?token=...', - helperMaxLines: 2, - helper: Markdown( - shrinkWrap: true, - padding: EdgeInsets.zero, - data: - 'You can visit your [Kagi Account Settings](user_details) to get your Session Link.', - onTapLink: (text, href, title) async { - if (href == 'user_details') { - await ui_helper.launchUrlFeedback( - context, - Uri.parse( - 'https://kagi.com/settings?p=user_details', - ), - ); - } - }, - ), - suffixIcon: IconButton( - onPressed: () { - hideSessionText.value = !hideSessionText.value; - }, - icon: Icon( - hideSessionText.value - ? Icons.visibility - : Icons.visibility_off, - ), - ), - ), - ), - ), - SwitchListTile.adaptive( - title: const Text('Show Early Access Features'), - subtitle: const Text( - "Displays Kagi's early access features in the UI. As an Ultimate subscriber, you will likely want to have this enabled.", - ), - value: settings.showEarlyAccessFeatures, - onChanged: (value) async { - await ref.read(saveSettingsControllerProvider.notifier).save( - (currentSettings) => currentSettings.copyWith - .showEarlyAccessFeatures(value), - ); - }, - ), - const SizedBox( - height: 16, - ), _buildSection(theme, 'General'), Padding( padding: @@ -232,19 +133,6 @@ class SettingsScreen extends HookConsumerWidget { ); }, ), - SwitchListTile.adaptive( - title: const Text('Launch Links Externally'), - subtitle: const Text( - 'Opens all links (except for kagi.com) in your default browser.', - ), - value: settings.launchUrlExternal, - onChanged: (value) async { - await ref.read(saveSettingsControllerProvider.notifier).save( - (currentSettings) => - currentSettings.copyWith.launchUrlExternal(value), - ); - }, - ), SwitchListTile.adaptive( title: const Text('Enable Reader Mode'), subtitle: const Text( @@ -261,77 +149,6 @@ class SettingsScreen extends HookConsumerWidget { const SizedBox( height: 16, ), - _buildSection(theme, 'Appearance'), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const CustomListTile( - title: 'Quick Action', - subtitle: - 'Appears in the browser app bar between website title and tab count.', - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Center( - child: SegmentedButton( - emptySelectionAllowed: true, - segments: [ - ButtonSegment( - value: KagiTool.search, - icon: Icon(KagiTool.search.icon), - label: const Text('Search'), - ), - ButtonSegment( - value: KagiTool.summarizer, - icon: Icon(KagiTool.summarizer.icon), - label: const Text('Summarizer'), - ), - ButtonSegment( - value: KagiTool.assistant, - icon: Icon(KagiTool.assistant.icon), - label: const Text('Assistant'), - ), - ], - selected: { - if (settings.quickAction != null) - settings.quickAction!, - }, - onSelectionChanged: (value) async { - await ref - .read(saveSettingsControllerProvider.notifier) - .save( - (currentSettings) => - currentSettings.copyWith.quickAction( - value.isNotEmpty ? value.first : null, - ), - ); - }, - ), - ), - ), - ], - ), - ), - SwitchListTile.adaptive( - title: const Text('Quick Action - STT'), - subtitle: const Text( - 'Quick option will open with speech-to-text input.', - ), - value: settings.quickActionVoiceInput, - onChanged: (settings.quickAction != null) - ? (value) async { - await ref - .read(saveSettingsControllerProvider.notifier) - .save( - (currentSettings) => currentSettings.copyWith - .quickActionVoiceInput(value), - ); - } - : null, - ), _buildSection(theme, 'Content Blocking'), _buildSubSection(theme, 'Lists'), const SizedBox( @@ -354,7 +171,7 @@ class SettingsScreen extends HookConsumerWidget { Consumer( builder: (context, ref, child) { final size = ref.watch( - bangIconCacheSizeMegabytesProvider.select( + iconCacheSizeMegabytesProvider.select( (value) => value.valueOrNull, ), ); @@ -384,8 +201,8 @@ class SettingsScreen extends HookConsumerWidget { suffix: FilledButton.icon( onPressed: () async { await ref - .read(bangDataRepositoryProvider.notifier) - .clearIconData(); + .read(cacheRepositoryProvider.notifier) + .clearCache(); }, icon: const Icon(Icons.delete), label: const Text('Clear'), diff --git a/app/lib/features/settings/utils/preference_parser.dart b/app/lib/features/settings/utils/preference_parser.dart deleted file mode 100644 index 7910f47c..00000000 --- a/app/lib/features/settings/utils/preference_parser.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lensai/features/kagi/data/entities/modes.dart'; - -ThemeMode? parseThemeMode(int? index) { - if (index != null && index < ThemeMode.values.length) { - return ThemeMode.values[index]; - } - - return null; -} - -KagiTool? parseKagiTool(int? index) { - if (index != null && index < ThemeMode.values.length) { - return KagiTool.values[index]; - } - - return null; -} diff --git a/app/lib/features/settings/utils/session_link_extractor.dart b/app/lib/features/settings/utils/session_link_extractor.dart deleted file mode 100644 index 4ee236f3..00000000 --- a/app/lib/features/settings/utils/session_link_extractor.dart +++ /dev/null @@ -1,7 +0,0 @@ -String? extractKagiSession(Uri uri) { - if (uri.authority == 'kagi.com') { - return uri.queryParameters['token']; - } - - return null; -} diff --git a/app/lib/features/user/data/database/daos/cache.dart b/app/lib/features/user/data/database/daos/cache.dart new file mode 100644 index 00000000..9867bea6 --- /dev/null +++ b/app/lib/features/user/data/database/daos/cache.dart @@ -0,0 +1,42 @@ +import 'package:drift/drift.dart'; +import 'package:lensai/extensions/database_table_size.dart'; +import 'package:lensai/features/user/data/database/database.dart'; + +part 'cache.g.dart'; + +@DriftAccessor() +class CacheDao extends DatabaseAccessor with _$CacheDaoMixin { + CacheDao(super.attachedDatabase); + + SingleSelectable iconCacheSize() { + return db.tableSize(db.iconCache); + } + + Future clearIconCache() { + return db.iconCache.deleteAll(); + } + + SingleOrNullSelectable getCachedIcon(String origin) { + final query = selectOnly(db.iconCache) + ..addColumns([db.iconCache.iconData]) + ..where(db.iconCache.origin.equals(origin)); + + return query.map((row) => row.read(db.iconCache.iconData)); + } + + Future cacheIcon(String origin, Uint8List bytes) { + return db.iconCache.insertOne( + IconCacheCompanion.insert( + origin: origin, + iconData: bytes, + fetchDate: DateTime.now(), + ), + onConflict: DoUpdate( + (old) => IconCacheCompanion.custom( + iconData: Variable(bytes), + fetchDate: Variable(DateTime.now()), + ), + ), + ); + } +} diff --git a/app/lib/features/user/data/database/daos/cache.g.dart b/app/lib/features/user/data/database/daos/cache.g.dart new file mode 100644 index 00000000..5b292224 --- /dev/null +++ b/app/lib/features/user/data/database/daos/cache.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cache.dart'; + +// ignore_for_file: type=lint +mixin _$CacheDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/user/data/database/daos/setting.dart b/app/lib/features/user/data/database/daos/setting.dart new file mode 100644 index 00000000..c01b7837 --- /dev/null +++ b/app/lib/features/user/data/database/daos/setting.dart @@ -0,0 +1,27 @@ +import 'package:drift/drift.dart'; +import 'package:lensai/features/user/data/database/database.dart'; + +part 'setting.g.dart'; + +@DriftAccessor() +class SettingDao extends DatabaseAccessor with _$SettingDaoMixin { + SettingDao(super.attachedDatabase); + + Future updateSetting(String key, Object? value) { + final driftvalue = (value != null) ? DriftAny(value) : null; + + return db.setting.insertOne( + SettingCompanion.insert( + key: key, + value: Value(driftvalue), + ), + onConflict: DoUpdate( + (old) => SettingCompanion.custom(value: Variable(driftvalue)), + ), + ); + } + + Selectable> allSettings() { + return db.setting.select().map((row) => MapEntry(row.key, row.value)); + } +} diff --git a/app/lib/features/user/data/database/daos/setting.g.dart b/app/lib/features/user/data/database/daos/setting.g.dart new file mode 100644 index 00000000..a031d6ce --- /dev/null +++ b/app/lib/features/user/data/database/daos/setting.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'setting.dart'; + +// ignore_for_file: type=lint +mixin _$SettingDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/user/data/database/database.dart b/app/lib/features/user/data/database/database.dart new file mode 100644 index 00000000..148c1c70 --- /dev/null +++ b/app/lib/features/user/data/database/database.dart @@ -0,0 +1,25 @@ +import 'package:drift/drift.dart'; +import 'package:lensai/features/user/data/database/daos/cache.dart'; +import 'package:lensai/features/user/data/database/daos/setting.dart'; + +part 'database.g.dart'; + +@DriftDatabase(include: { + 'database.drift' +}, daos: [ + SettingDao, + CacheDao, +]) +class UserDatabase extends _$UserDatabase { + @override + final int schemaVersion = 1; + + @override + MigrationStrategy get migration => MigrationStrategy( + beforeOpen: (details) async { + await customStatement('PRAGMA foreign_keys = ON;'); + }, + ); + + UserDatabase(super.e); +} diff --git a/app/lib/features/user/data/database/database.drift b/app/lib/features/user/data/database/database.drift new file mode 100644 index 00000000..a90c949e --- /dev/null +++ b/app/lib/features/user/data/database/database.drift @@ -0,0 +1,19 @@ +CREATE TABLE setting ( + "key" TEXT PRIMARY KEY NOT NULL, + "value" ANY +) STRICT; + +CREATE TABLE icon_cache ( + origin TEXT PRIMARY KEY NOT NULL, + icon_data BLOB NOT NULL, + fetch_date DATETIME NOT NULL +); + +evictCacheEntries: + DELETE FROM icon_cache + WHERE rowid IN ( + SELECT rowid + FROM icon_cache + ORDER BY fetch_date DESC + LIMIT -1 OFFSET :limit + ); \ No newline at end of file diff --git a/app/lib/features/user/data/database/database.g.dart b/app/lib/features/user/data/database/database.g.dart new file mode 100644 index 00000000..fbc52301 --- /dev/null +++ b/app/lib/features/user/data/database/database.g.dart @@ -0,0 +1,653 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class Setting extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Setting(this.attachedDatabase, [this._alias]); + late final GeneratedColumn key = GeneratedColumn( + 'key', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL'); + late final GeneratedColumn value = GeneratedColumn( + 'value', aliasedName, true, + type: DriftSqlType.any, + requiredDuringInsert: false, + $customConstraints: ''); + @override + List get $columns => [key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'setting'; + @override + Set get $primaryKey => {key}; + @override + SettingData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SettingData( + key: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}key'])!, + value: attachedDatabase.typeMapping + .read(DriftSqlType.any, data['${effectivePrefix}value']), + ); + } + + @override + Setting createAlias(String alias) { + return Setting(attachedDatabase, alias); + } + + @override + bool get isStrict => true; + @override + bool get dontWriteConstraints => true; +} + +class SettingData extends DataClass implements Insertable { + final String key; + final DriftAny? value; + const SettingData({required this.key, this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + if (!nullToAbsent || value != null) { + map['value'] = Variable(value); + } + return map; + } + + factory SettingData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SettingData( + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + SettingData copyWith( + {String? key, Value value = const Value.absent()}) => + SettingData( + key: key ?? this.key, + value: value.present ? value.value : this.value, + ); + SettingData copyWithCompanion(SettingCompanion data) { + return SettingData( + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('SettingData(') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SettingData && + other.key == this.key && + other.value == this.value); +} + +class SettingCompanion extends UpdateCompanion { + final Value key; + final Value value; + final Value rowid; + const SettingCompanion({ + this.key = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + SettingCompanion.insert({ + required String key, + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }) : key = Value(key); + static Insertable custom({ + Expression? key, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + SettingCompanion copyWith( + {Value? key, Value? value, Value? rowid}) { + return SettingCompanion( + key: key ?? this.key, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SettingCompanion(') + ..write('key: $key, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class IconCache extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + IconCache(this.attachedDatabase, [this._alias]); + late final GeneratedColumn origin = GeneratedColumn( + 'origin', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL'); + late final GeneratedColumn iconData = GeneratedColumn( + 'icon_data', aliasedName, false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + late final GeneratedColumn fetchDate = GeneratedColumn( + 'fetch_date', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + @override + List get $columns => [origin, iconData, fetchDate]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'icon_cache'; + @override + Set get $primaryKey => {origin}; + @override + IconCacheData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return IconCacheData( + origin: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}origin'])!, + iconData: attachedDatabase.typeMapping + .read(DriftSqlType.blob, data['${effectivePrefix}icon_data'])!, + fetchDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}fetch_date'])!, + ); + } + + @override + IconCache createAlias(String alias) { + return IconCache(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class IconCacheData extends DataClass implements Insertable { + final String origin; + final Uint8List iconData; + final DateTime fetchDate; + const IconCacheData( + {required this.origin, required this.iconData, required this.fetchDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['origin'] = Variable(origin); + map['icon_data'] = Variable(iconData); + map['fetch_date'] = Variable(fetchDate); + return map; + } + + factory IconCacheData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return IconCacheData( + origin: serializer.fromJson(json['origin']), + iconData: serializer.fromJson(json['icon_data']), + fetchDate: serializer.fromJson(json['fetch_date']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'origin': serializer.toJson(origin), + 'icon_data': serializer.toJson(iconData), + 'fetch_date': serializer.toJson(fetchDate), + }; + } + + IconCacheData copyWith( + {String? origin, Uint8List? iconData, DateTime? fetchDate}) => + IconCacheData( + origin: origin ?? this.origin, + iconData: iconData ?? this.iconData, + fetchDate: fetchDate ?? this.fetchDate, + ); + IconCacheData copyWithCompanion(IconCacheCompanion data) { + return IconCacheData( + origin: data.origin.present ? data.origin.value : this.origin, + iconData: data.iconData.present ? data.iconData.value : this.iconData, + fetchDate: data.fetchDate.present ? data.fetchDate.value : this.fetchDate, + ); + } + + @override + String toString() { + return (StringBuffer('IconCacheData(') + ..write('origin: $origin, ') + ..write('iconData: $iconData, ') + ..write('fetchDate: $fetchDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(origin, $driftBlobEquality.hash(iconData), fetchDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is IconCacheData && + other.origin == this.origin && + $driftBlobEquality.equals(other.iconData, this.iconData) && + other.fetchDate == this.fetchDate); +} + +class IconCacheCompanion extends UpdateCompanion { + final Value origin; + final Value iconData; + final Value fetchDate; + final Value rowid; + const IconCacheCompanion({ + this.origin = const Value.absent(), + this.iconData = const Value.absent(), + this.fetchDate = const Value.absent(), + this.rowid = const Value.absent(), + }); + IconCacheCompanion.insert({ + required String origin, + required Uint8List iconData, + required DateTime fetchDate, + this.rowid = const Value.absent(), + }) : origin = Value(origin), + iconData = Value(iconData), + fetchDate = Value(fetchDate); + static Insertable custom({ + Expression? origin, + Expression? iconData, + Expression? fetchDate, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (origin != null) 'origin': origin, + if (iconData != null) 'icon_data': iconData, + if (fetchDate != null) 'fetch_date': fetchDate, + if (rowid != null) 'rowid': rowid, + }); + } + + IconCacheCompanion copyWith( + {Value? origin, + Value? iconData, + Value? fetchDate, + Value? rowid}) { + return IconCacheCompanion( + origin: origin ?? this.origin, + iconData: iconData ?? this.iconData, + fetchDate: fetchDate ?? this.fetchDate, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (origin.present) { + map['origin'] = Variable(origin.value); + } + if (iconData.present) { + map['icon_data'] = Variable(iconData.value); + } + if (fetchDate.present) { + map['fetch_date'] = Variable(fetchDate.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('IconCacheCompanion(') + ..write('origin: $origin, ') + ..write('iconData: $iconData, ') + ..write('fetchDate: $fetchDate, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$UserDatabase extends GeneratedDatabase { + _$UserDatabase(QueryExecutor e) : super(e); + $UserDatabaseManager get managers => $UserDatabaseManager(this); + late final Setting setting = Setting(this); + late final IconCache iconCache = IconCache(this); + late final SettingDao settingDao = SettingDao(this as UserDatabase); + late final CacheDao cacheDao = CacheDao(this as UserDatabase); + Future evictCacheEntries({required int limit}) { + return customUpdate( + 'DELETE FROM icon_cache WHERE "rowid" IN (SELECT "rowid" FROM icon_cache ORDER BY fetch_date DESC LIMIT -1 OFFSET ?1)', + variables: [Variable(limit)], + updates: {iconCache}, + updateKind: UpdateKind.delete, + ); + } + + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [setting, iconCache]; +} + +typedef $SettingCreateCompanionBuilder = SettingCompanion Function({ + required String key, + Value value, + Value rowid, +}); +typedef $SettingUpdateCompanionBuilder = SettingCompanion Function({ + Value key, + Value value, + Value rowid, +}); + +class $SettingFilterComposer extends Composer<_$UserDatabase, Setting> { + $SettingFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get key => $composableBuilder( + column: $table.key, builder: (column) => ColumnFilters(column)); + + ColumnFilters get value => $composableBuilder( + column: $table.value, builder: (column) => ColumnFilters(column)); +} + +class $SettingOrderingComposer extends Composer<_$UserDatabase, Setting> { + $SettingOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get key => $composableBuilder( + column: $table.key, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get value => $composableBuilder( + column: $table.value, builder: (column) => ColumnOrderings(column)); +} + +class $SettingAnnotationComposer extends Composer<_$UserDatabase, Setting> { + $SettingAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get key => + $composableBuilder(column: $table.key, builder: (column) => column); + + GeneratedColumn get value => + $composableBuilder(column: $table.value, builder: (column) => column); +} + +class $SettingTableManager extends RootTableManager< + _$UserDatabase, + Setting, + SettingData, + $SettingFilterComposer, + $SettingOrderingComposer, + $SettingAnnotationComposer, + $SettingCreateCompanionBuilder, + $SettingUpdateCompanionBuilder, + (SettingData, BaseReferences<_$UserDatabase, Setting, SettingData>), + SettingData, + PrefetchHooks Function()> { + $SettingTableManager(_$UserDatabase db, Setting table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $SettingFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $SettingOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $SettingAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value key = const Value.absent(), + Value value = const Value.absent(), + Value rowid = const Value.absent(), + }) => + SettingCompanion( + key: key, + value: value, + rowid: rowid, + ), + createCompanionCallback: ({ + required String key, + Value value = const Value.absent(), + Value rowid = const Value.absent(), + }) => + SettingCompanion.insert( + key: key, + value: value, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $SettingProcessedTableManager = ProcessedTableManager< + _$UserDatabase, + Setting, + SettingData, + $SettingFilterComposer, + $SettingOrderingComposer, + $SettingAnnotationComposer, + $SettingCreateCompanionBuilder, + $SettingUpdateCompanionBuilder, + (SettingData, BaseReferences<_$UserDatabase, Setting, SettingData>), + SettingData, + PrefetchHooks Function()>; +typedef $IconCacheCreateCompanionBuilder = IconCacheCompanion Function({ + required String origin, + required Uint8List iconData, + required DateTime fetchDate, + Value rowid, +}); +typedef $IconCacheUpdateCompanionBuilder = IconCacheCompanion Function({ + Value origin, + Value iconData, + Value fetchDate, + Value rowid, +}); + +class $IconCacheFilterComposer extends Composer<_$UserDatabase, IconCache> { + $IconCacheFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get origin => $composableBuilder( + column: $table.origin, builder: (column) => ColumnFilters(column)); + + ColumnFilters get iconData => $composableBuilder( + column: $table.iconData, builder: (column) => ColumnFilters(column)); + + ColumnFilters get fetchDate => $composableBuilder( + column: $table.fetchDate, builder: (column) => ColumnFilters(column)); +} + +class $IconCacheOrderingComposer extends Composer<_$UserDatabase, IconCache> { + $IconCacheOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get origin => $composableBuilder( + column: $table.origin, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get iconData => $composableBuilder( + column: $table.iconData, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get fetchDate => $composableBuilder( + column: $table.fetchDate, builder: (column) => ColumnOrderings(column)); +} + +class $IconCacheAnnotationComposer extends Composer<_$UserDatabase, IconCache> { + $IconCacheAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get origin => + $composableBuilder(column: $table.origin, builder: (column) => column); + + GeneratedColumn get iconData => + $composableBuilder(column: $table.iconData, builder: (column) => column); + + GeneratedColumn get fetchDate => + $composableBuilder(column: $table.fetchDate, builder: (column) => column); +} + +class $IconCacheTableManager extends RootTableManager< + _$UserDatabase, + IconCache, + IconCacheData, + $IconCacheFilterComposer, + $IconCacheOrderingComposer, + $IconCacheAnnotationComposer, + $IconCacheCreateCompanionBuilder, + $IconCacheUpdateCompanionBuilder, + (IconCacheData, BaseReferences<_$UserDatabase, IconCache, IconCacheData>), + IconCacheData, + PrefetchHooks Function()> { + $IconCacheTableManager(_$UserDatabase db, IconCache table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $IconCacheFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $IconCacheOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $IconCacheAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value origin = const Value.absent(), + Value iconData = const Value.absent(), + Value fetchDate = const Value.absent(), + Value rowid = const Value.absent(), + }) => + IconCacheCompanion( + origin: origin, + iconData: iconData, + fetchDate: fetchDate, + rowid: rowid, + ), + createCompanionCallback: ({ + required String origin, + required Uint8List iconData, + required DateTime fetchDate, + Value rowid = const Value.absent(), + }) => + IconCacheCompanion.insert( + origin: origin, + iconData: iconData, + fetchDate: fetchDate, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $IconCacheProcessedTableManager = ProcessedTableManager< + _$UserDatabase, + IconCache, + IconCacheData, + $IconCacheFilterComposer, + $IconCacheOrderingComposer, + $IconCacheAnnotationComposer, + $IconCacheCreateCompanionBuilder, + $IconCacheUpdateCompanionBuilder, + (IconCacheData, BaseReferences<_$UserDatabase, IconCache, IconCacheData>), + IconCacheData, + PrefetchHooks Function()>; + +class $UserDatabaseManager { + final _$UserDatabase _db; + $UserDatabaseManager(this._db); + $SettingTableManager get setting => $SettingTableManager(_db, _db.setting); + $IconCacheTableManager get iconCache => + $IconCacheTableManager(_db, _db.iconCache); +} diff --git a/app/lib/features/settings/data/models/settings.dart b/app/lib/features/user/data/models/settings.dart similarity index 56% rename from app/lib/features/settings/data/models/settings.dart rename to app/lib/features/user/data/models/settings.dart index 02dc5ad0..67e25262 100644 --- a/app/lib/features/settings/data/models/settings.dart +++ b/app/lib/features/user/data/models/settings.dart @@ -1,70 +1,53 @@ import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:fast_equatable/fast_equatable.dart'; import 'package:flutter/material.dart'; -import 'package:lensai/features/kagi/data/entities/modes.dart'; +import 'package:json_annotation/json_annotation.dart'; part 'settings.g.dart'; @CopyWith() +@JsonSerializable(includeIfNull: true, constructor: 'withDefaults') class Settings with FastEquatable { - final String? kagiSession; - final bool showEarlyAccessFeatures; final bool incognitoMode; final bool enableJavascript; - final bool launchUrlExternal; final bool blockHttpProtocol; final ThemeMode themeMode; - final KagiTool? quickAction; - final bool quickActionVoiceInput; final bool enableReadability; Settings({ - required this.kagiSession, - required this.showEarlyAccessFeatures, required this.incognitoMode, required this.enableJavascript, - required this.launchUrlExternal, required this.blockHttpProtocol, required this.themeMode, - required this.quickAction, - required this.quickActionVoiceInput, required this.enableReadability, }); Settings.withDefaults({ - this.kagiSession, - bool? showEarlyAccessFeatures, bool? incognitoMode, bool? enableJavascript, - bool? launchUrlExternal, bool? blockHttpProtocol, ThemeMode? themeMode, - this.quickAction, - bool? quickActionVoiceInput, bool? enableReadability, - }) : showEarlyAccessFeatures = showEarlyAccessFeatures ?? true, - incognitoMode = incognitoMode ?? true, + }) : incognitoMode = incognitoMode ?? true, enableJavascript = enableJavascript ?? true, - launchUrlExternal = launchUrlExternal ?? false, blockHttpProtocol = blockHttpProtocol ?? false, themeMode = themeMode ?? ThemeMode.dark, - quickActionVoiceInput = quickActionVoiceInput ?? false, enableReadability = enableReadability ?? true; + factory Settings.fromJson(Map json) => + _$SettingsFromJson(json); + + Map toJson() => _$SettingsToJson(this); + @override bool get cacheHash => true; @override List get hashParameters => [ - kagiSession, - showEarlyAccessFeatures, incognitoMode, enableJavascript, - launchUrlExternal, blockHttpProtocol, themeMode, - quickAction, - quickActionVoiceInput, enableReadability, ]; } diff --git a/app/lib/features/settings/data/models/settings.g.dart b/app/lib/features/user/data/models/settings.g.dart similarity index 62% rename from app/lib/features/settings/data/models/settings.g.dart rename to app/lib/features/user/data/models/settings.g.dart index ac623d2f..ae1ce99c 100644 --- a/app/lib/features/settings/data/models/settings.g.dart +++ b/app/lib/features/user/data/models/settings.g.dart @@ -7,24 +7,14 @@ part of 'settings.dart'; // ************************************************************************** abstract class _$SettingsCWProxy { - Settings kagiSession(String? kagiSession); - - Settings showEarlyAccessFeatures(bool showEarlyAccessFeatures); - Settings incognitoMode(bool incognitoMode); Settings enableJavascript(bool enableJavascript); - Settings launchUrlExternal(bool launchUrlExternal); - Settings blockHttpProtocol(bool blockHttpProtocol); Settings themeMode(ThemeMode themeMode); - Settings quickAction(KagiTool? quickAction); - - Settings quickActionVoiceInput(bool quickActionVoiceInput); - Settings enableReadability(bool enableReadability); /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `Settings(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. @@ -34,15 +24,10 @@ abstract class _$SettingsCWProxy { /// Settings(...).copyWith(id: 12, name: "My name") /// ```` Settings call({ - String? kagiSession, - bool showEarlyAccessFeatures, bool incognitoMode, bool enableJavascript, - bool launchUrlExternal, bool blockHttpProtocol, ThemeMode themeMode, - KagiTool? quickAction, - bool quickActionVoiceInput, bool enableReadability, }); } @@ -53,13 +38,6 @@ class _$SettingsCWProxyImpl implements _$SettingsCWProxy { final Settings _value; - @override - Settings kagiSession(String? kagiSession) => this(kagiSession: kagiSession); - - @override - Settings showEarlyAccessFeatures(bool showEarlyAccessFeatures) => - this(showEarlyAccessFeatures: showEarlyAccessFeatures); - @override Settings incognitoMode(bool incognitoMode) => this(incognitoMode: incognitoMode); @@ -68,10 +46,6 @@ class _$SettingsCWProxyImpl implements _$SettingsCWProxy { Settings enableJavascript(bool enableJavascript) => this(enableJavascript: enableJavascript); - @override - Settings launchUrlExternal(bool launchUrlExternal) => - this(launchUrlExternal: launchUrlExternal); - @override Settings blockHttpProtocol(bool blockHttpProtocol) => this(blockHttpProtocol: blockHttpProtocol); @@ -79,13 +53,6 @@ class _$SettingsCWProxyImpl implements _$SettingsCWProxy { @override Settings themeMode(ThemeMode themeMode) => this(themeMode: themeMode); - @override - Settings quickAction(KagiTool? quickAction) => this(quickAction: quickAction); - - @override - Settings quickActionVoiceInput(bool quickActionVoiceInput) => - this(quickActionVoiceInput: quickActionVoiceInput); - @override Settings enableReadability(bool enableReadability) => this(enableReadability: enableReadability); @@ -99,27 +66,13 @@ class _$SettingsCWProxyImpl implements _$SettingsCWProxy { /// Settings(...).copyWith(id: 12, name: "My name") /// ```` Settings call({ - Object? kagiSession = const $CopyWithPlaceholder(), - Object? showEarlyAccessFeatures = const $CopyWithPlaceholder(), Object? incognitoMode = const $CopyWithPlaceholder(), Object? enableJavascript = const $CopyWithPlaceholder(), - Object? launchUrlExternal = const $CopyWithPlaceholder(), Object? blockHttpProtocol = const $CopyWithPlaceholder(), Object? themeMode = const $CopyWithPlaceholder(), - Object? quickAction = const $CopyWithPlaceholder(), - Object? quickActionVoiceInput = const $CopyWithPlaceholder(), Object? enableReadability = const $CopyWithPlaceholder(), }) { return Settings( - kagiSession: kagiSession == const $CopyWithPlaceholder() - ? _value.kagiSession - // ignore: cast_nullable_to_non_nullable - : kagiSession as String?, - showEarlyAccessFeatures: - showEarlyAccessFeatures == const $CopyWithPlaceholder() - ? _value.showEarlyAccessFeatures - // ignore: cast_nullable_to_non_nullable - : showEarlyAccessFeatures as bool, incognitoMode: incognitoMode == const $CopyWithPlaceholder() ? _value.incognitoMode // ignore: cast_nullable_to_non_nullable @@ -128,10 +81,6 @@ class _$SettingsCWProxyImpl implements _$SettingsCWProxy { ? _value.enableJavascript // ignore: cast_nullable_to_non_nullable : enableJavascript as bool, - launchUrlExternal: launchUrlExternal == const $CopyWithPlaceholder() - ? _value.launchUrlExternal - // ignore: cast_nullable_to_non_nullable - : launchUrlExternal as bool, blockHttpProtocol: blockHttpProtocol == const $CopyWithPlaceholder() ? _value.blockHttpProtocol // ignore: cast_nullable_to_non_nullable @@ -140,15 +89,6 @@ class _$SettingsCWProxyImpl implements _$SettingsCWProxy { ? _value.themeMode // ignore: cast_nullable_to_non_nullable : themeMode as ThemeMode, - quickAction: quickAction == const $CopyWithPlaceholder() - ? _value.quickAction - // ignore: cast_nullable_to_non_nullable - : quickAction as KagiTool?, - quickActionVoiceInput: - quickActionVoiceInput == const $CopyWithPlaceholder() - ? _value.quickActionVoiceInput - // ignore: cast_nullable_to_non_nullable - : quickActionVoiceInput as bool, enableReadability: enableReadability == const $CopyWithPlaceholder() ? _value.enableReadability // ignore: cast_nullable_to_non_nullable @@ -162,3 +102,29 @@ extension $SettingsCopyWith on Settings { // ignore: library_private_types_in_public_api _$SettingsCWProxy get copyWith => _$SettingsCWProxyImpl(this); } + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Settings _$SettingsFromJson(Map json) => Settings.withDefaults( + incognitoMode: json['incognitoMode'] as bool?, + enableJavascript: json['enableJavascript'] as bool?, + blockHttpProtocol: json['blockHttpProtocol'] as bool?, + themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']), + enableReadability: json['enableReadability'] as bool?, + ); + +Map _$SettingsToJson(Settings instance) => { + 'incognitoMode': instance.incognitoMode, + 'enableJavascript': instance.enableJavascript, + 'blockHttpProtocol': instance.blockHttpProtocol, + 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, + 'enableReadability': instance.enableReadability, + }; + +const _$ThemeModeEnumMap = { + ThemeMode.system: 'system', + ThemeMode.light: 'light', + ThemeMode.dark: 'dark', +}; diff --git a/app/lib/features/user/data/providers.dart b/app/lib/features/user/data/providers.dart new file mode 100644 index 00000000..35e2770c --- /dev/null +++ b/app/lib/features/user/data/providers.dart @@ -0,0 +1,38 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:lensai/features/user/data/database/database.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart' as path_provider; +import 'package:riverpod/riverpod.dart'; +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(keepAlive: true) +UserDatabase userDatabase(Ref ref) { + return UserDatabase( + LazyDatabase(() async { + // put the database file, called db.sqlite here, into the documents folder + // for your app. + final dbFolder = await path_provider.getApplicationDocumentsDirectory(); + final file = File(p.join(dbFolder.path, 'user.db')); + + // 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.createInBackground(file); + }), + ); +} diff --git a/app/lib/features/user/data/providers.g.dart b/app/lib/features/user/data/providers.g.dart new file mode 100644 index 00000000..6609562a --- /dev/null +++ b/app/lib/features/user/data/providers.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$userDatabaseHash() => r'a84f951e1673e34b6857e1618cd7d80497c6f3a1'; + +/// See also [userDatabase]. +@ProviderFor(userDatabase) +final userDatabaseProvider = Provider.internal( + userDatabase, + name: r'userDatabaseProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$userDatabaseHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef UserDatabaseRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/user/domain/providers/cache.dart b/app/lib/features/user/domain/providers/cache.dart new file mode 100644 index 00000000..94d88634 --- /dev/null +++ b/app/lib/features/user/domain/providers/cache.dart @@ -0,0 +1,11 @@ +import 'package:lensai/features/user/data/providers.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'cache.g.dart'; + +@Riverpod() +Stream iconCacheSizeMegabytes(Ref ref) { + final repository = ref.watch(userDatabaseProvider); + return repository.cacheDao.iconCacheSize().watchSingle(); +} diff --git a/app/lib/features/user/domain/providers/cache.g.dart b/app/lib/features/user/domain/providers/cache.g.dart new file mode 100644 index 00000000..fe704035 --- /dev/null +++ b/app/lib/features/user/domain/providers/cache.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cache.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$iconCacheSizeMegabytesHash() => + r'43162fce3ce8b04a5a55ed52021b274afbde2861'; + +/// See also [iconCacheSizeMegabytes]. +@ProviderFor(iconCacheSizeMegabytes) +final iconCacheSizeMegabytesProvider = + AutoDisposeStreamProvider.internal( + iconCacheSizeMegabytes, + name: r'iconCacheSizeMegabytesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$iconCacheSizeMegabytesHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef IconCacheSizeMegabytesRef = AutoDisposeStreamProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/user/domain/repositories/cache.dart b/app/lib/features/user/domain/repositories/cache.dart new file mode 100644 index 00000000..9bb5f8f7 --- /dev/null +++ b/app/lib/features/user/domain/repositories/cache.dart @@ -0,0 +1,42 @@ +import 'dart:typed_data'; + +import 'package:lensai/features/geckoview/domain/providers.dart'; +import 'package:lensai/features/user/data/database/database.dart'; +import 'package:lensai/features/user/data/providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'cache.g.dart'; + +@Riverpod(keepAlive: true) +class CacheRepository extends _$CacheRepository { + late UserDatabase _db; + + Future clearCache() { + return _db.cacheDao.clearIconCache(); + } + + Future cacheIcon(Uri url, Uint8List bytes) { + return _db.cacheDao.cacheIcon(url.origin, bytes); + } + + Future getCachedIcon(String origin) { + return _db.cacheDao.getCachedIcon(origin).getSingleOrNull(); + } + + @override + void build() { + final eventService = ref.watch(eventServiceProvider); + + _db = ref.watch(userDatabaseProvider); + + final sub = eventService.iconUpdateEvents.listen((event) async { + if (Uri.tryParse(event.url) case final Uri url) { + await _db.cacheDao.cacheIcon(url.origin, event.bytes); + } + }); + + ref.onDispose(() async { + await sub.cancel(); + }); + } +} diff --git a/app/lib/features/user/domain/repositories/cache.g.dart b/app/lib/features/user/domain/repositories/cache.g.dart new file mode 100644 index 00000000..40ad20f5 --- /dev/null +++ b/app/lib/features/user/domain/repositories/cache.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cache.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$cacheRepositoryHash() => r'37b25ff308b9f25575625984e55cb02f9c0293f0'; + +/// See also [CacheRepository]. +@ProviderFor(CacheRepository) +final cacheRepositoryProvider = + NotifierProvider.internal( + CacheRepository.new, + name: r'cacheRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$cacheRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$CacheRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/user/domain/repositories/settings.dart b/app/lib/features/user/domain/repositories/settings.dart new file mode 100644 index 00000000..1d16efd4 --- /dev/null +++ b/app/lib/features/user/domain/repositories/settings.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:lensai/features/user/data/database/database.dart'; +import 'package:lensai/features/user/data/models/settings.dart'; +import 'package:lensai/features/user/data/providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'settings.g.dart'; + +typedef UpdateSettingsFunc = Settings Function(Settings currentSettings); + +@Riverpod(keepAlive: true) +class SettingsRepository extends _$SettingsRepository { + late UserDatabase _db; + + Future updateSettings(UpdateSettingsFunc updateWithCurrent) { + final oldJson = state.toJson(); + final newJson = updateWithCurrent(state).toJson(); + + return _db.transaction(() async { + for (final MapEntry(:key, :value) in newJson.entries) { + if (oldJson[key] != value) { + await _db.settingDao.updateSetting(key, value); + } + } + }); + } + + @override + Settings build() { + _db = ref.watch(userDatabaseProvider); + + final watchSub = _db.settingDao.allSettings().watch().listen((entries) { + final settings = Map.fromEntries(entries); + + state = Settings.fromJson({ + 'incognitoMode': settings['incognitoMode'] + ?.readAs(DriftSqlType.bool, _db.typeMapping), + 'enableJavascript': settings['enableJavascript'] + ?.readAs(DriftSqlType.bool, _db.typeMapping), + 'blockHttpProtocol': settings['blockHttpProtocol'] + ?.readAs(DriftSqlType.bool, _db.typeMapping), + 'themeMode': + settings['themeMode']?.readAs(DriftSqlType.string, _db.typeMapping), + 'enableReadability': settings['enableReadability'] + ?.readAs(DriftSqlType.bool, _db.typeMapping), + }); + }); + + ref.onDispose(() { + unawaited(watchSub.cancel()); + }); + + return Settings.withDefaults(); + } +} diff --git a/app/lib/features/settings/data/repositories/settings_repository.g.dart b/app/lib/features/user/domain/repositories/settings.g.dart similarity index 79% rename from app/lib/features/settings/data/repositories/settings_repository.g.dart rename to app/lib/features/user/domain/repositories/settings.g.dart index cdcec015..a0213564 100644 --- a/app/lib/features/settings/data/repositories/settings_repository.g.dart +++ b/app/lib/features/user/domain/repositories/settings.g.dart @@ -1,18 +1,18 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'settings_repository.dart'; +part of 'settings.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** String _$settingsRepositoryHash() => - r'b0a498899546b6a0c8566458ed8848fef410c537'; + r'7e3e032957f5092aa3d445d2b7b05a882e3cfd20'; /// See also [SettingsRepository]. @ProviderFor(SettingsRepository) final settingsRepositoryProvider = - AsyncNotifierProvider.internal( + NotifierProvider.internal( SettingsRepository.new, name: r'settingsRepositoryProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -22,6 +22,6 @@ final settingsRepositoryProvider = allTransitiveDependencies: null, ); -typedef _$SettingsRepository = AsyncNotifier; +typedef _$SettingsRepository = Notifier; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package 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 5b34647c..9a332913 100644 --- a/app/lib/features/web_view/presentation/widgets/web_view.dart +++ b/app/lib/features/web_view/presentation/widgets/web_view.dart @@ -126,7 +126,7 @@ // final showEarlyAccessFeatures = ref.watch( // settingsRepositoryProvider.select( -// (value) => (value.valueOrNull ?? Settings.withDefaults()) +// (value) => value // .showEarlyAccessFeatures, // ), // ); @@ -148,7 +148,7 @@ // ref.listen( // settingsRepositoryProvider.select( // (value) => -// (value.valueOrNull ?? Settings.withDefaults()).enableJavascript, +// value.enableJavascript, // ), // (previous, next) async { // final controller = ref.read(webViewControllerProvider(widget.tabId)); @@ -409,7 +409,7 @@ // if (url != null) { // final launchExternal = ref.read( // settingsRepositoryProvider.select( -// (value) => (value.valueOrNull ?? Settings.withDefaults()) +// (value) => value // .launchUrlExternal, // ), // ); diff --git a/app/lib/main.dart b/app/lib/main.dart index 7d119745..89b15b0d 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -4,8 +4,7 @@ import 'package:home_widget/home_widget.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/core/error_observer.dart'; import 'package:lensai/domain/services/app_initialization.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; +import 'package:lensai/features/user/domain/repositories/settings.dart'; import 'package:lensai/presentation/hooks/on_initialization.dart'; import 'package:lensai/presentation/widgets/main_app.dart'; @@ -25,8 +24,7 @@ void main() async { builder: (context, ref, child) { final themeMode = ref.watch( settingsRepositoryProvider.select( - (value) => - (value.valueOrNull ?? Settings.withDefaults()).themeMode, + (value) => value.themeMode, ), ); diff --git a/app/lib/presentation/controllers/website_title.dart b/app/lib/presentation/controllers/website_title.dart index d186992e..f15f5d1a 100644 --- a/app/lib/presentation/controllers/website_title.dart +++ b/app/lib/presentation/controllers/website_title.dart @@ -8,5 +8,5 @@ part 'website_title.g.dart'; @Riverpod(keepAlive: true) Future pageInfo(Ref ref, Uri url) { final websiteService = ref.watch(genericWebsiteServiceProvider.notifier); - return websiteService.getInfo(url).then((value) => value.value); + return websiteService.fetchPageInfo(url).then((value) => value.value); } diff --git a/app/lib/presentation/controllers/website_title.g.dart b/app/lib/presentation/controllers/website_title.g.dart index 9d96ed86..a322f8fa 100644 --- a/app/lib/presentation/controllers/website_title.g.dart +++ b/app/lib/presentation/controllers/website_title.g.dart @@ -6,7 +6,7 @@ part of 'website_title.dart'; // RiverpodGenerator // ************************************************************************** -String _$pageInfoHash() => r'f83bf4b19670abedf0fac5cfe59f190c37544123'; +String _$pageInfoHash() => r'f777a45aba30c82cf7b8790c07a804be364cfeb1'; /// Copied from Dart SDK class _SystemHash { diff --git a/app/lib/presentation/widgets/website_title_tile.dart b/app/lib/presentation/widgets/website_title_tile.dart index 012492df..24ba350d 100644 --- a/app/lib/presentation/widgets/website_title_tile.dart +++ b/app/lib/presentation/widgets/website_title_tile.dart @@ -13,7 +13,9 @@ class WebsiteTitleTile extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final pageInfoAsync = ref.watch(pageInfoProvider(url)); + final pageInfoAsync = (precachedInfo?.isPageInfoComplete ?? false) + ? AsyncValue.data(precachedInfo!) + : ref.watch(pageInfoProvider(url)); return Skeletonizer( enabled: pageInfoAsync.isLoading && precachedInfo == null, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoTabsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoTabsApiImpl.kt index 5177919d..b765324f 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoTabsApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoTabsApiImpl.kt @@ -115,21 +115,19 @@ class GeckoTabsApiImpl : GeckoTabsApi { private suspend fun handleIconChange(tab: SessionState) { try { + val iconBytes: ByteArray? if (tab.content.icon != null) { - val iconBytes = tab.content.icon?.toWebPBytes() - withContext(Dispatchers.Main) { - components.flutterEvents.onIconChange( - System.currentTimeMillis(), - tab.id, - iconBytes - ) { } - } + iconBytes = tab.content.icon?.toWebPBytes() } else { - val result = components.core.icons.loadIcon(IconRequest(url = tab.content.url)).await() - val iconBytes = result.bitmap.toWebPBytes() - withContext(Dispatchers.Main) { - components.flutterEvents.onIconChange(System.currentTimeMillis(), tab.id, iconBytes) { } - } + iconBytes = null + } + + withContext(Dispatchers.Main) { + components.flutterEvents.onIconChange( + System.currentTimeMillis(), + tab.id, + iconBytes + ) { } } } catch (e: Exception) { logger.error("$TAG: Failed to handle icon change for tab ${tab.id}", e) diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt index 482d8bf9..919d2865 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt @@ -70,6 +70,17 @@ class FlutterEventMiddleware(private val flutterEvents: GeckoStateEvents) : Midd ) { _ -> } } } + is ContentAction.UpdateIconAction -> { + val bytes = action.icon.toWebPBytes() + + runOnUiThread { + flutterEvents.onIconUpdate( + System.currentTimeMillis(), + action.pageUrl, + bytes + ) { _ -> } + } + } else -> { //logger.debug("Event fired: " + action.javaClass.name) } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt index 2a42aa6a..b7768de8 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v22.7.0), do not edit directly. +// Autogenerated from Pigeon (v22.7.1), do not edit directly. // See also: https://pub.dev/packages/pigeon @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -2430,6 +2430,23 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } + fun onIconUpdate(timestampArg: Long, urlArg: String, bytesArg: ByteArray, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconUpdate$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, urlArg, bytesArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(createConnectionError(channelName))) + } + } + } fun onTabAdded(timestampArg: Long, tabIdArg: String, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_event.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_event.dart index 022e4425..0b4bc7e4 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_event.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_event.dart @@ -8,7 +8,8 @@ import 'package:rxdart/rxdart.dart'; typedef HistoryEvent = ({String tabId, HistoryState history}); typedef ReaderableEvent = ({String tabId, ReaderableState readerable}); typedef SecurityInfoEvent = ({String tabId, SecurityInfoState securityInfo}); -typedef IconEvent = ({String tabId, Uint8List? bytes}); +typedef IconChangeEvent = ({String tabId, Uint8List? bytes}); +typedef IconUpdateEvent = ({String url, Uint8List bytes}); typedef ThumbnailEvent = ({String tabId, Uint8List? bytes}); typedef FindResultsEvent = ({String tabId, List results}); @@ -24,7 +25,8 @@ class GeckoEventService extends GeckoStateEvents { final _securityInfoSubject = ReplaySubject(); final _readerableSubject = ReplaySubject(); - final _iconSubject = PublishSubject(); + final _iconChangeSubject = PublishSubject(); + final _iconUpdateSubject = PublishSubject(); final _thumbnailSubject = PublishSubject(); final _findResultsSubject = PublishSubject(); @@ -41,7 +43,8 @@ class GeckoEventService extends GeckoStateEvents { Stream get readerableEvents => _readerableSubject.stream; Stream get securityInfoEvents => _securityInfoSubject.stream; - Stream get iconEvents => _iconSubject.stream; + Stream get iconChangeEvents => _iconChangeSubject.stream; + Stream get iconUpdateEvents => _iconUpdateSubject.stream; Stream get thumbnailEvents => _thumbnailSubject.stream; Stream get findResultsEvent => _findResultsSubject.stream; @@ -114,7 +117,14 @@ class GeckoEventService extends GeckoStateEvents { @override void onIconChange(int timestamp, String id, Uint8List? bytes) { - _iconSubject.addWhenMoreRecent(timestamp, id, (tabId: id, bytes: bytes)); + _iconChangeSubject + .addWhenMoreRecent(timestamp, id, (tabId: id, bytes: bytes)); + } + + @override + void onIconUpdate(int timestamp, String url, Uint8List bytes) { + _iconUpdateSubject + .addWhenMoreRecent(timestamp, url, (url: url, bytes: bytes)); } @override @@ -162,7 +172,7 @@ class GeckoEventService extends GeckoStateEvents { unawaited(_historySubject.close()); unawaited(_readerableSubject.close()); unawaited(_securityInfoSubject.close()); - unawaited(_iconSubject.close()); + unawaited(_iconChangeSubject.close()); unawaited(_thumbnailSubject.close()); unawaited(_findResultsSubject.close()); unawaited(_tabAddedSubject.close()); diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_icon.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_icon.dart index 4ff99368..c68a7120 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_icon.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_icon.dart @@ -10,14 +10,17 @@ class GeckoIconService { Future loadIcon({ required Uri url, List resources = const [], + IconSize size = IconSize.defaultSize, + bool isPrivate = false, + bool waitOnNetworkLoad = true, }) async { return _api.loadIcon( IconRequest( url: url.toString(), - size: IconSize.defaultSize, + size: size, resources: resources, - isPrivate: false, - waitOnNetworkLoad: true, + isPrivate: isPrivate, + waitOnNetworkLoad: waitOnNetworkLoad, ), ); } diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index f02e5042..d6fd3b39 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v22.7.0), do not edit directly. +// Autogenerated from Pigeon (v22.7.1), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers @@ -2492,6 +2492,8 @@ abstract class GeckoStateEvents { void onEngineReadyStateChange(int timestamp, bool state); + void onIconUpdate(int timestamp, String url, Uint8List bytes); + void onTabAdded(int timestamp, String tabId); void onTabListChange(int timestamp, List tabIds); @@ -2570,6 +2572,37 @@ abstract class GeckoStateEvents { }); } } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconUpdate$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconUpdate was null.'); + final List args = (message as List?)!; + final int? arg_timestamp = (args[0] as int?); + assert(arg_timestamp != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconUpdate was null, expected non-null int.'); + final String? arg_url = (args[1] as String?); + assert(arg_url != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconUpdate was null, expected non-null String.'); + final Uint8List? arg_bytes = (args[2] as Uint8List?); + assert(arg_bytes != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconUpdate was null, expected non-null Uint8List.'); + try { + api.onIconUpdate(arg_timestamp!, arg_url!, arg_bytes!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( 'dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabAdded$messageChannelSuffix', pigeonChannelCodec, diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index 57beabc2..4e4a6c15 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -853,6 +853,7 @@ abstract class GeckoCookieApi { abstract class GeckoStateEvents { void onViewReadyStateChange(int timestamp, bool state); void onEngineReadyStateChange(int timestamp, bool state); + void onIconUpdate(int timestamp, String url, Uint8List bytes); void onTabAdded(int timestamp, String tabId);