intermediate

This commit is contained in:
Fabian Freund
2024-12-12 09:44:33 +01:00
parent 0710116feb
commit 6b2ae28828
108 changed files with 5531 additions and 2265 deletions
@@ -49,6 +49,7 @@
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:supportsPictureInPicture="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
-25
View File
@@ -1,25 +0,0 @@
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Compiled output
build/
# Optional npm cache directory
.npm/
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
-1362
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,27 +0,0 @@
{
"name": "readability",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@mozilla/readability": "^0.5.0",
"dompurify": "^3.1.6"
},
"devDependencies": {
"@babel/core": "^7.24.7",
"@babel/preset-env": "^7.24.7",
"babel-loader": "^9.1.3",
"clean-webpack-plugin": "^4.0.0",
"compression-webpack-plugin": "^11.1.0",
"terser-webpack-plugin": "^5.3.10",
"webpack": "^5.92.1",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^5.1.4"
}
}
-39
View File
@@ -1,39 +0,0 @@
import { Readability, isProbablyReaderable } from '@mozilla/readability';
import DOMPurify from 'dompurify';
function isReaderable() {
return isProbablyReaderable(document);
}
function parseReaderable(doc) {
const reader = new Readability(doc);
const article = reader.parse();
return article;
}
function applyReaderable() {
const clonedDoc = document.cloneNode(true);
const article = parseReaderable(clonedDoc);
const markup = DOMPurify.sanitize(article.content);
// console.log('article title: ', parsed.title);
// console.log('HTML string of processed article content: ', parsed.content);
// console.log('text content of the article, with all the HTML tags removed: ', parsed.textContent);
// console.log('length of an article, in characters: ', parsed.length);
// console.log('article description, or short excerpt from the content: ', parsed.excerpt);
// console.log('author metadata: ', parsed.byline);
// console.log('content direction: ', parsed.dir);
// console.log('name of the site: ', parsed.siteName);
// console.log('content language: ', parsed.lang);
// console.log('published time: ', parsed.publishedTime);
if (markup) {
document.body.innerHTML = '<div style="margin: 4pt;">' + markup + '</div>';
}
}
// Attach functions to the window object
window.isReaderable = isReaderable;
window.applyReaderable = applyReaderable;
export { isReaderable, applyReaderable };
-54
View File
@@ -1,54 +0,0 @@
const path = require('path');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const TerserPlugin = require('terser-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'readability.min.js',
library: 'ReadabilityBundle',
libraryTarget: 'umd',
globalObject: 'this'
},
mode: 'production',
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}
]
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
extractComments: false
})
],
usedExports: true,
sideEffects: true
},
plugins: [
new CleanWebpackPlugin(),
new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: false,
generateStatsFile: true,
statsOptions: { source: false }
}),
new CompressionPlugin({
algorithm: 'gzip',
test: /\.js$/
})
]
};
+37 -3
View File
@@ -8,8 +8,9 @@ import 'package:lensai/features/bangs/presentation/screens/search.dart';
import 'package:lensai/features/chat_archive/presentation/screens/detail.dart';
import 'package:lensai/features/chat_archive/presentation/screens/list.dart';
import 'package:lensai/features/chat_archive/presentation/screens/search.dart';
import 'package:lensai/features/geckoview/features/browser/screens/browser.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/screens/browser.dart';
import 'package:lensai/features/geckoview/features/tabs/presentation/screens/container_list.dart';
import 'package:lensai/features/search/presentation/screens/search.dart';
import 'package:lensai/features/settings/presentation/screens/settings.dart';
part 'routes.g.dart';
@@ -22,13 +23,17 @@ part 'routes.g.dart';
name: 'AboutRoute',
path: 'about',
),
TypedGoRoute<SearchRoute>(
name: 'SearchRoute',
path: 'search/:searchText',
),
TypedGoRoute<BangCategoriesRoute>(
name: 'BangRoute',
path: 'bangs',
routes: [
TypedGoRoute<BangSearchRoute>(
name: 'BangSearchRoute',
path: 'search',
path: 'search/:searchText',
),
TypedGoRoute<BangCategoryRoute>(
name: 'BangCategoryRoute',
@@ -62,6 +67,24 @@ class AboutRoute extends GoRouteData {
}
}
class SearchRoute extends GoRouteData {
static const String emptySearchText = ' ';
//This should be nullable but isnt allowed by go_router
final String searchText;
const SearchRoute({this.searchText = SearchRoute.emptySearchText});
@override
Widget build(BuildContext context, GoRouterState state) {
return SearchScreen(
initialSearchText: (searchText.isEmpty || searchText == emptySearchText)
? null
: searchText,
);
}
}
class BangCategoriesRoute extends GoRouteData {
@override
Widget build(BuildContext context, GoRouterState state) {
@@ -96,9 +119,20 @@ class BangSubCategoryRoute extends GoRouteData {
}
class BangSearchRoute extends GoRouteData {
static const String emptySearchText = ' ';
//This should be nullable but isnt allowed by go_router
final String searchText;
const BangSearchRoute({this.searchText = BangSearchRoute.emptySearchText});
@override
Widget build(BuildContext context, GoRouterState state) {
return const BangSearchScreen();
return BangSearchScreen(
initialSearchText: (searchText.isEmpty || searchText == emptySearchText)
? null
: searchText,
);
}
}
+31 -3
View File
@@ -22,13 +22,18 @@ RouteBase get $browserRoute => GoRouteData.$route(
name: 'AboutRoute',
factory: $AboutRouteExtension._fromState,
),
GoRouteData.$route(
path: 'search/:searchText',
name: 'SearchRoute',
factory: $SearchRouteExtension._fromState,
),
GoRouteData.$route(
path: 'bangs',
name: 'BangRoute',
factory: $BangCategoriesRouteExtension._fromState,
routes: [
GoRouteData.$route(
path: 'search',
path: 'search/:searchText',
name: 'BangSearchRoute',
factory: $BangSearchRouteExtension._fromState,
),
@@ -88,6 +93,26 @@ extension $AboutRouteExtension on AboutRoute {
void replace(BuildContext context) => context.replace(location);
}
extension $SearchRouteExtension on SearchRoute {
static SearchRoute _fromState(GoRouterState state) => SearchRoute(
searchText:
state.pathParameters['searchText']! ?? SearchRoute.emptySearchText,
);
String get location => GoRouteData.$location(
'/search/${Uri.encodeComponent(searchText)}',
);
void go(BuildContext context) => context.go(location);
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
void replace(BuildContext context) => context.replace(location);
}
extension $BangCategoriesRouteExtension on BangCategoriesRoute {
static BangCategoriesRoute _fromState(GoRouterState state) =>
BangCategoriesRoute();
@@ -107,10 +132,13 @@ extension $BangCategoriesRouteExtension on BangCategoriesRoute {
}
extension $BangSearchRouteExtension on BangSearchRoute {
static BangSearchRoute _fromState(GoRouterState state) => BangSearchRoute();
static BangSearchRoute _fromState(GoRouterState state) => BangSearchRoute(
searchText: state.pathParameters['searchText']! ??
BangSearchRoute.emptySearchText,
);
String get location => GoRouteData.$location(
'/bangs/search',
'/bangs/search/${Uri.encodeComponent(searchText)}',
);
void go(BuildContext context) => context.go(location);
+55 -45
View File
@@ -10,8 +10,8 @@ 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/utils/lru_cache.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:universal_io/io.dart';
part 'generic_website.g.dart';
@@ -56,11 +56,11 @@ class GenericWebsiteService extends _$GenericWebsiteService {
late http.Client _client;
final Map<String, bool> _httpsCache;
final LRUCache<String, BrowserIcon> _iconCache;
GenericWebsiteService()
: _httpsCache = {},
_iconsService = GeckoIconService();
: _iconsService = GeckoIconService(),
_iconCache = LRUCache(50);
@override
void build() {
@@ -207,49 +207,59 @@ class GenericWebsiteService extends _$GenericWebsiteService {
required Uri url,
List<Resource> resources = const [],
}) async {
final result = await _iconsService.loadIcon(url: url, resources: resources);
return BrowserIcon.fromBytes(
result.image,
dominantColor: (result.color != null) ? Color(result.color!) : null,
source: result.source,
);
}
final cached = _iconCache.get(url.host);
if (cached == null) {
final result =
await _iconsService.loadIcon(url: url, resources: resources);
Future<Uri?> tryUpgradeToHttps(Uri httpUri) async {
if (httpUri.isScheme('https')) {
return httpUri;
} else if (httpUri.isScheme('http')) {
final cached = _httpsCache[httpUri.host];
if (cached != null) {
return cached ? httpUri.replace(scheme: 'https') : null;
}
var sslAvailable = false;
try {
final context = SecurityContext.defaultContext;
final socket = await SecureSocket.connect(
httpUri.host,
443,
context: context,
timeout: const Duration(seconds: 3),
);
await socket.close();
sslAvailable = true;
} catch (_) {
sslAvailable = false;
}
_httpsCache[httpUri.host] = sslAvailable;
if (sslAvailable) {
return httpUri.replace(scheme: 'https');
}
return _iconCache.set(
url.host,
await BrowserIcon.fromBytes(
result.image,
dominantColor: (result.color != null) ? Color(result.color!) : null,
source: result.source,
),
);
}
return null;
return cached;
}
// Future<Uri?> tryUpgradeToHttps(Uri httpUri) async {
// if (httpUri.isScheme('https')) {
// return httpUri;
// } else if (httpUri.isScheme('http')) {
// final cached = _httpsCache[httpUri.host];
// if (cached != null) {
// return cached ? httpUri.replace(scheme: 'https') : null;
// }
// var sslAvailable = false;
// try {
// final context = SecurityContext.defaultContext;
// final socket = await SecureSocket.connect(
// httpUri.host,
// 443,
// context: context,
// timeout: const Duration(seconds: 3),
// );
// await socket.close();
// sslAvailable = true;
// } catch (_) {
// sslAvailable = false;
// }
// _httpsCache[httpUri.host] = sslAvailable;
// if (sslAvailable) {
// return httpUri.replace(scheme: 'https');
// }
// }
// return null;
// }
}
@@ -7,7 +7,7 @@ part of 'generic_website.dart';
// **************************************************************************
String _$genericWebsiteServiceHash() =>
r'0b1d953fe1b30b9dd0396df34e6176424cdaf941';
r'7dea42d6914b0f06250a96bb425ba10a331ef0d3';
/// See also [GenericWebsiteService].
@ProviderFor(GenericWebsiteService)
@@ -91,7 +91,7 @@ class BangDao extends DatabaseAccessor<BangDatabase> with _$BangDaoMixin {
}
Selectable<BangData> queryBangs(String searchString) {
return db.bangQuery(query: db.buildQuery(searchString));
return db.bangQuery(query: db.buildFtsQuery(searchString));
}
// Future<int> upsertBangIcon(String trigger, BrowserIcon icon) async {
@@ -4,7 +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/query/domain/tokenizer.dart';
import 'package:lensai/features/search/domain/fts_tokenizer.dart';
part 'database.g.dart';
@@ -5,7 +5,7 @@ import 'package:lensai/features/bangs/domain/repositories/sync.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
part 'bangs.g.dart';
@Riverpod()
Stream<BangData?> bangData(Ref ref, String? trigger) {
@@ -14,7 +14,7 @@ Stream<BangData?> bangData(Ref ref, String? trigger) {
}
@Riverpod(keepAlive: true)
Stream<BangData?> kagiSearchBangData(Ref ref) {
Stream<BangData?> defaultSearchBangData(Ref ref) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
return repository.watchBang('kagi');
}
@@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
part of 'bangs.dart';
// **************************************************************************
// RiverpodGenerator
@@ -156,24 +156,24 @@ class _BangDataProviderElement
String? get trigger => (origin as BangDataProvider).trigger;
}
String _$kagiSearchBangDataHash() =>
r'0bb5014a7af73ef6f5a36b0545d7a73e7ad0580a';
String _$defaultSearchBangDataHash() =>
r'b7547084b261ccb1dc02a6c7230fe57f6902dd58';
/// See also [kagiSearchBangData].
@ProviderFor(kagiSearchBangData)
final kagiSearchBangDataProvider = StreamProvider<BangData?>.internal(
kagiSearchBangData,
name: r'kagiSearchBangDataProvider',
/// See also [defaultSearchBangData].
@ProviderFor(defaultSearchBangData)
final defaultSearchBangDataProvider = StreamProvider<BangData?>.internal(
defaultSearchBangData,
name: r'defaultSearchBangDataProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$kagiSearchBangDataHash,
: _$defaultSearchBangDataHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef KagiSearchBangDataRef = StreamProviderRef<BangData?>;
typedef DefaultSearchBangDataRef = StreamProviderRef<BangData?>;
String _$bangCategoriesHash() => r'947fcfd2dffcc7f585c6ed7379d319f4fe72293a';
/// See also [bangCategories].
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:lensai/features/bangs/data/database/database.dart';
import 'package:lensai/features/bangs/data/models/bang_data.dart';
import 'package:lensai/features/bangs/data/providers.dart';
import 'package:lensai/features/bangs/domain/providers/bangs.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'search.g.dart';
@@ -30,3 +31,29 @@ class BangSearch extends _$BangSearch {
return _streamController.stream;
}
}
@Riverpod()
class SeamlessBangProvider extends _$SeamlessBangProvider {
bool _hasSearch = false;
void search(String input) {
if (input.isNotEmpty) {
if (!_hasSearch) {
_hasSearch = true;
ref.invalidateSelf();
}
unawaited(ref.read(bangSearchProvider.notifier).search(input));
} else if (_hasSearch) {
_hasSearch = false;
ref.invalidateSelf();
}
}
@override
AsyncValue<List<BangData>> build() {
return _hasSearch
? ref.watch(bangSearchProvider)
: ref.watch(frequentBangDataListProvider);
}
}
@@ -21,5 +21,23 @@ final bangSearchProvider =
);
typedef _$BangSearch = AutoDisposeStreamNotifier<List<BangData>>;
String _$seamlessBangProviderHash() =>
r'd2c8e94d6305a6a37cc27f8bc3617521f513e537';
/// See also [SeamlessBangProvider].
@ProviderFor(SeamlessBangProvider)
final seamlessBangProviderProvider = AutoDisposeNotifierProvider<
SeamlessBangProvider, AsyncValue<List<BangData>>>.internal(
SeamlessBangProvider.new,
name: r'seamlessBangProviderProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$seamlessBangProviderHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SeamlessBangProvider
= AutoDisposeNotifier<AsyncValue<List<BangData>>>;
// 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
@@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
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.dart';
import 'package:lensai/features/bangs/domain/providers/bangs.dart';
import 'package:lensai/presentation/widgets/failure_widget.dart';
class BangCategoriesScreen extends HookConsumerWidget {
@@ -19,7 +19,7 @@ class BangCategoriesScreen extends HookConsumerWidget {
actions: [
IconButton(
onPressed: () async {
await context.push(BangSearchRoute().location);
await context.push(const BangSearchRoute().location);
},
icon: const Icon(Icons.search),
),
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
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.dart';
import 'package:lensai/features/bangs/domain/providers/bangs.dart';
import 'package:lensai/features/bangs/presentation/widgets/bang_details.dart';
import 'package:lensai/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:lensai/features/geckoview/features/browser/domain/providers.dart';
@@ -5,20 +5,18 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
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/repositories/search.dart';
import 'package:lensai/features/bangs/presentation/widgets/bang_details.dart';
import 'package:lensai/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:lensai/features/geckoview/features/browser/domain/providers.dart';
import 'package:lensai/features/geckoview/features/controllers/bottom_sheet.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/presentation/hooks/listenable_callback.dart';
import 'package:lensai/presentation/widgets/failure_widget.dart';
class BangSearchScreen extends HookConsumerWidget {
const BangSearchScreen({super.key});
final String? initialSearchText;
const BangSearchScreen({super.key, this.initialSearchText});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -29,7 +27,8 @@ class BangSearchScreen extends HookConsumerWidget {
),
);
final textEditingController = useTextEditingController();
final textEditingController =
useTextEditingController(text: initialSearchText);
useListenableCallback(textEditingController, () async {
unawaited(
@@ -78,14 +77,15 @@ class BangSearchScreen extends HookConsumerWidget {
.read(selectedBangTriggerProvider().notifier)
.setTrigger(bang.trigger);
if (ref.read(bottomSheetControllerProvider)
is! CreateTabSheet) {
ref.read(bottomSheetControllerProvider.notifier).show(
CreateTabSheet(preferredTool: KagiTool.search),
);
}
// if (ref.read(bottomSheetControllerProvider)
// is! CreateTabSheet) {
// ref.read(bottomSheetControllerProvider.notifier).show(
// CreateTabSheet(preferredTool: KagiTool.search),
// );
// }
context.go(BrowserRoute().location);
// context.go(BrowserRoute().location);
context.pop();
},
);
},
@@ -2,7 +2,7 @@ import 'package:flutter/material.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/providers.dart';
import 'package:lensai/features/bangs/domain/providers/bangs.dart';
import 'package:skeletonizer/skeletonizer.dart';
class BangIcon extends HookConsumerWidget {
@@ -4,9 +4,9 @@ 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/kagi/domain/repositories/autosuggest.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/autocomplete.dart';
@@ -36,7 +36,7 @@ class SearchField extends HookConsumerWidget {
),
);
final optionsStream = ref.watch(autosuggestRepositoryProvider);
final optionsStream = ref.watch(searchSuggestionsProvider());
final focusNode = useFocusNode();
@@ -69,7 +69,7 @@ class SearchField extends HookConsumerWidget {
},
onTextChanged: (textEditingValue) {
ref
.read(autosuggestRepositoryProvider.notifier)
.read(searchSuggestionsProvider().notifier)
.addQuery(textEditingValue.text);
},
fieldViewBuilder: (
@@ -39,7 +39,7 @@ class SearchDao extends DatabaseAccessor<ChatSearchDatabase>
required String searchString,
}) {
return db.chatQuery(
query: db.buildQuery(searchString),
query: db.buildFtsQuery(searchString),
snippetLength: snippetLength,
beforeMatch: matchPrefix,
afterMatch: matchSuffix,
@@ -1,6 +1,6 @@
import 'package:drift/drift.dart';
import 'package:lensai/features/chat_archive/data/database/daos/search.dart';
import 'package:lensai/features/query/domain/tokenizer.dart';
import 'package:lensai/features/search/domain/fts_tokenizer.dart';
part 'database.g.dart';
@@ -28,7 +28,7 @@ CREATE TRIGGER chat_after_update AFTER UPDATE ON chat BEGIN
chat_fts(chat_fts, rowid, title, content)
VALUES('delete', old.rowid, old.title, old.content);
INSERT INTO
chat_fts(chat_fts, title, content)
chat_fts(rowid, title, content)
VALUES (new.rowid, new.title, new.content);
END;
@@ -380,7 +380,7 @@ abstract class _$ChatSearchDatabase extends GeneratedDatabase {
'CREATE TRIGGER chat_after_delete AFTER DELETE ON chat BEGIN INSERT INTO chat_fts (chat_fts, "rowid", title, content) VALUES (\'delete\', old."rowid", old.title, old.content);END',
'chat_after_delete');
late final Trigger chatAfterUpdate = Trigger(
'CREATE TRIGGER chat_after_update AFTER UPDATE ON chat BEGIN INSERT INTO chat_fts (chat_fts, "rowid", title, content) VALUES (\'delete\', old."rowid", old.title, old.content);INSERT INTO chat_fts (chat_fts, title, content) VALUES (new."rowid", new.title, new.content);END',
'CREATE TRIGGER chat_after_update AFTER UPDATE ON chat BEGIN INSERT INTO chat_fts (chat_fts, "rowid", title, content) VALUES (\'delete\', old."rowid", old.title, old.content);INSERT INTO chat_fts ("rowid", title, content) VALUES (new."rowid", new.title, new.content);END',
'chat_after_update');
late final SearchDao searchDao = SearchDao(this as ChatSearchDatabase);
Selectable<ChatQueryResult> chatQuery(
@@ -83,10 +83,6 @@ class ChatArchiveSearchRepository extends _$ChatArchiveSearchRepository {
_streamController = StreamController();
_searchDatabase = ref.watch(chatSearchDatabaseProvider);
ref.onDispose(() async {
await _streamController.close();
});
// populate with initial chats
await _searchDatabase.searchDao.deleteAllChats();
await _searchDatabase.searchDao.indexChats(await _availableChats());
@@ -112,6 +108,7 @@ class ChatArchiveSearchRepository extends _$ChatArchiveSearchRepository {
ref.onDispose(() async {
await changeStreamSubscription.cancel();
await _streamController.close();
});
yield* _streamController.stream;
@@ -7,7 +7,7 @@ part of 'search.dart';
// **************************************************************************
String _$chatArchiveSearchRepositoryHash() =>
r'a3decc7975bf49995b20ea578db3c08b69f7c03c';
r'd7174cfb877c61ee4e9580f44d7ffd11bf3b4b60';
/// See also [ChatArchiveSearchRepository].
@ProviderFor(ChatArchiveSearchRepository)
@@ -2,7 +2,7 @@ import 'dart:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:lensai/core/logger.dart';
import 'package:lensai/features/bangs/domain/providers.dart';
import 'package:lensai/features/bangs/domain/providers/bangs.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_session.dart';
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
import 'package:riverpod/riverpod.dart';
@@ -22,7 +22,7 @@ GeckoSelectionActionService selectionActionService(
service.setActions([
SearchAction((text) async {
final defaultSearchBang =
await ref.read(kagiSearchBangDataProvider.future);
await ref.read(defaultSearchBangDataProvider.future);
if (defaultSearchBang != null) {
await ref
@@ -34,7 +34,7 @@ GeckoSelectionActionService selectionActionService(
}),
PrivateSearchAction((text) async {
final defaultSearchBang =
await ref.read(kagiSearchBangDataProvider.future);
await ref.read(defaultSearchBangDataProvider.future);
if (defaultSearchBang != null) {
await ref.read(tabRepositoryProvider.notifier).addTab(
@@ -96,6 +96,17 @@ GeckoAddonService addonService(Ref ref) {
return service;
}
@Riverpod(keepAlive: true)
GeckoTabContentService tabContentService(Ref ref) {
final service = GeckoTabContentService.setUp();
ref.onDispose(() {
service.dispose();
});
return service;
}
@Riverpod(keepAlive: true)
class EngineReadyState extends _$EngineReadyState {
@override
@@ -133,5 +144,5 @@ class EngineReadyState extends _$EngineReadyState {
@Riverpod()
Raw<TabSession> selectedTabSessionNotifier(Ref ref) {
return ref.watch(tabSessionProvider(null).notifier);
return ref.watch(tabSessionProvider(tabId: null).notifier);
}
@@ -7,7 +7,7 @@ part of 'providers.dart';
// **************************************************************************
String _$selectionActionServiceHash() =>
r'b5bc6b2bc5496ae84d61a74cc6ff052b55d2a3d3';
r'd0c04262171976c21441297706273417e3d79b67';
/// See also [selectionActionService].
@ProviderFor(selectionActionService)
@@ -57,8 +57,25 @@ final addonServiceProvider = Provider<GeckoAddonService>.internal(
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AddonServiceRef = ProviderRef<GeckoAddonService>;
String _$tabContentServiceHash() => r'd9a991add907ecc138c62790883e59d8e9aa9266';
/// See also [tabContentService].
@ProviderFor(tabContentService)
final tabContentServiceProvider = Provider<GeckoTabContentService>.internal(
tabContentService,
name: r'tabContentServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$tabContentServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef TabContentServiceRef = ProviderRef<GeckoTabContentService>;
String _$selectedTabSessionNotifierHash() =>
r'62fcd98a915566d55b5bcb297d87d675fe12786d';
r'aef19991d5b05a2bb28e8a6ccb57dc75c2f3148f';
/// See also [selectedTabSessionNotifier].
@ProviderFor(selectedTabSessionNotifier)
@@ -2,22 +2,16 @@ import 'dart:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:lensai/features/geckoview/domain/providers.dart';
import 'package:lensai/features/geckoview/features/tabs/data/database/database.dart';
import 'package:lensai/features/geckoview/features/tabs/data/providers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'selected_tab.g.dart';
@Riverpod(keepAlive: true)
class SelectedTab extends _$SelectedTab {
late TabDatabase _db;
@override
String? build() {
final eventSerivce = ref.watch(eventServiceProvider);
_db = ref.watch(tabDatabaseProvider);
ref.listen(
fireImmediately: true,
engineReadyStateProvider,
@@ -31,10 +25,6 @@ class SelectedTab extends _$SelectedTab {
final selectedTabSub = eventSerivce.selectedTabEvents.listen(
(tabId) async {
state = tabId;
if (tabId != null) {
await _db.tabDao.touchTab(tabId, timestamp: DateTime.now());
}
},
);
@@ -6,7 +6,7 @@ part of 'selected_tab.dart';
// RiverpodGenerator
// **************************************************************************
String _$selectedTabHash() => r'e49fb1c3938a97f384673e158742e56479aa8a8f';
String _$selectedTabHash() => r'f550ac5b471bab8f8e5d75ef4d7669cde769db9a';
/// See also [SelectedTab].
@ProviderFor(SelectedTab)
@@ -3,22 +3,16 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:lensai/features/geckoview/domain/providers.dart';
import 'package:lensai/features/geckoview/features/tabs/data/database/database.dart';
import 'package:lensai/features/geckoview/features/tabs/data/providers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'tab_list.g.dart';
@Riverpod(keepAlive: true)
class TabList extends _$TabList {
late TabDatabase _db;
@override
List<String> build() {
final eventService = ref.watch(eventServiceProvider);
_db = ref.watch(tabDatabaseProvider);
ref.listen(
fireImmediately: true,
engineReadyStateProvider,
@@ -32,15 +26,7 @@ class TabList extends _$TabList {
final tabListSub = eventService.tabListEvents.listen(
(tabs) async {
if (!const DeepCollectionEquality().equals(state, tabs)) {
//Make sure we only syncing empty lists when there has been tabs
//before as security measurement
final syncTabs = tabs.isNotEmpty || state.isNotEmpty;
state = tabs;
if (syncTabs) {
await _db.tabDao.syncTabs(retainTabIds: tabs);
}
}
},
);
@@ -6,7 +6,7 @@ part of 'tab_list.dart';
// RiverpodGenerator
// **************************************************************************
String _$tabListHash() => r'3c17372cc467736ce1cfba937999acf4de7345ef';
String _$tabListHash() => r'039099bfa102a86fd73c0544ea71f93f91e9d352';
/// See also [TabList].
@ProviderFor(TabList)
@@ -42,7 +42,7 @@ class TabSession extends _$TabSession {
}
@override
void build(String? tabId) {
void build({required String? tabId}) {
_sessionService = (tabId != null)
? GeckoSessionService(tabId: tabId)
: GeckoSessionService.forActiveTab();
@@ -6,7 +6,7 @@ part of 'tab_session.dart';
// RiverpodGenerator
// **************************************************************************
String _$tabSessionHash() => r'42b44a4d47b9a551d002fdbc6337c14b2c327dde';
String _$tabSessionHash() => r'e4fbbcd430037cc01adb799783fb7974a11a27bf';
/// Copied from Dart SDK
class _SystemHash {
@@ -32,9 +32,9 @@ class _SystemHash {
abstract class _$TabSession extends BuildlessAutoDisposeNotifier<void> {
late final String? tabId;
void build(
String? tabId,
);
void build({
required String? tabId,
});
}
/// See also [TabSession].
@@ -47,11 +47,11 @@ class TabSessionFamily extends Family<void> {
const TabSessionFamily();
/// See also [TabSession].
TabSessionProvider call(
String? tabId,
) {
TabSessionProvider call({
required String? tabId,
}) {
return TabSessionProvider(
tabId,
tabId: tabId,
);
}
@@ -60,7 +60,7 @@ class TabSessionFamily extends Family<void> {
covariant TabSessionProvider provider,
) {
return call(
provider.tabId,
tabId: provider.tabId,
);
}
@@ -83,9 +83,9 @@ class TabSessionFamily extends Family<void> {
class TabSessionProvider
extends AutoDisposeNotifierProviderImpl<TabSession, void> {
/// See also [TabSession].
TabSessionProvider(
String? tabId,
) : this._internal(
TabSessionProvider({
required String? tabId,
}) : this._internal(
() => TabSession()..tabId = tabId,
from: tabSessionProvider,
name: r'tabSessionProvider',
@@ -116,7 +116,7 @@ class TabSessionProvider
covariant TabSession notifier,
) {
return notifier.build(
tabId,
tabId: tabId,
);
}
@@ -2,10 +2,15 @@ import 'dart:async';
import 'package:drift/drift.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:lensai/features/geckoview/domain/entities/tab_state.dart';
import 'package:lensai/features/geckoview/domain/providers.dart';
import 'package:lensai/features/geckoview/domain/providers/selected_tab.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_list.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
import 'package:lensai/features/geckoview/features/tabs/data/database/database.dart';
import 'package:lensai/features/geckoview/features/tabs/data/providers.dart';
import 'package:lensai/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:lensai/utils/debouncer.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'tab.g.dart';
@@ -57,6 +62,10 @@ class TabRepository extends _$TabRepository {
@override
void build() {
final eventSerivce = ref.watch(eventServiceProvider);
final tabContentService = ref.watch(tabContentServiceProvider);
final tabStateDebouncer = Debouncer(const Duration(seconds: 3));
Map<String, TabState>? debounceStartValue;
_db = ref.watch(tabDatabaseProvider);
@@ -67,8 +76,58 @@ class TabRepository extends _$TabRepository {
},
);
final tabContentSub =
tabContentService.tabContentStream.listen((content) async {
await _db.tabDao.updateTab(
content.tabId,
extractedContentMarkdown: Value(content.extractedContentMarkdown),
extractedContentPlain: Value(content.extractedContentPlain),
fullContentMarkdown: Value(content.fullContentMarkdown),
fullContentPlain: Value(content.fullContentPlain),
);
});
ref.listen(
selectedTabProvider,
(previous, tabId) async {
if (tabId != null) {
await _db.tabDao.touchTab(tabId, timestamp: DateTime.now());
}
},
);
ref.listen(
tabListProvider,
(previous, next) async {
//Only sync tabs if there has been a previous value or is not empty
final syncTabs = next.isNotEmpty || (previous?.isNotEmpty ?? false);
if (syncTabs) {
await _db.tabDao.syncTabs(retainTabIds: next);
}
},
);
ref.listen(
tabStatesProvider,
(previous, next) async {
//Since state changes occure pretty often and our map always contains
//the latest state, we cache the value before starting debouncing and
//later diff to that, to avoid frequent database writes
if (!tabStateDebouncer.isDebouncing) {
debounceStartValue = previous;
}
tabStateDebouncer.eventOccured(() async {
await _db.tabDao.updateTabs(debounceStartValue, next);
});
},
);
ref.onDispose(() {
tabStateDebouncer.dispose();
unawaited(tabAddedSub.cancel());
unawaited(tabContentSub.cancel());
});
}
}
@@ -6,7 +6,7 @@ part of 'tab.dart';
// RiverpodGenerator
// **************************************************************************
String _$tabRepositoryHash() => r'05040223b83cd5ff80afaf89407043a10e4777cb';
String _$tabRepositoryHash() => r'b151be1c18d87eff3a15f4b70f4e82fcc11047ec';
/// See also [TabRepository].
@ProviderFor(TabRepository)
@@ -7,7 +7,7 @@ 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.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';
import 'package:lensai/features/geckoview/domain/providers/tab_session.dart';
@@ -92,7 +92,7 @@ class WebPageDialog extends HookConsumerWidget {
onPressed: () async {
if (formKey.currentState?.validate() ?? false) {
await ref
.read(tabSessionProvider(null).notifier)
.read(tabSessionProvider(tabId: null).notifier)
.loadUrl(
url: uri_parser.tryParseUrl(
urlTextController.text,
@@ -19,6 +19,7 @@ import 'package:lensai/features/geckoview/features/browser/domain/services/engin
import 'package:lensai/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/app_bar_title.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/browser_view.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/sheets/create_tab.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart';
@@ -47,6 +48,9 @@ class BrowserScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
//Init here to avoid dispose errors of menu items when called
final addonService = ref.watch(addonServiceProvider);
final displayedSheet = ref.watch(bottomSheetControllerProvider);
final displayedOverlayDialog = ref.watch(overlayDialogControllerProvider);
@@ -186,14 +190,15 @@ class BrowserScreen extends HookConsumerWidget {
color: (activeTool == KagiTool.search)
? Theme.of(context).colorScheme.primary
: null,
onPressed: () {
ref
.read(createTabStreamProvider.notifier)
.createTab(
CreateTabSheet(
preferredTool: KagiTool.search,
),
);
onPressed: () async {
// ref
// .read(createTabStreamProvider.notifier)
// .createTab(
// CreateTabSheet(
// preferredTool: KagiTool.search,
// ),
// );
await context.push(SearchRoute().location);
},
icon: Icon(KagiTool.search.icon),
),
@@ -337,30 +342,12 @@ class BrowserScreen extends HookConsumerWidget {
(extension) => IconButton(
onPressed: () async {
//Use parents .ref because after onPressed this consumer gets disposed already
await ref
.read(addonServiceProvider)
.invokeAddonAction(
extension.extensionId,
WebExtensionActionType.page,
);
await addonService.invokeAddonAction(
extension.extensionId,
WebExtensionActionType.page,
);
},
icon: Badge(
isLabelVisible:
extension.badgeText?.isNotEmpty ??
false,
label: (extension.badgeText?.isNotEmpty ==
true)
? Text(extension.badgeText!)
: null,
textColor: extension.badgeTextColor,
backgroundColor:
extension.badgeBackgroundColor,
child: RawImage(
image: extension.icon?.value,
width: 24,
height: 24,
),
),
icon: ExtensionBadgeIcon(extension),
),
),
],
@@ -383,8 +370,8 @@ class BrowserScreen extends HookConsumerWidget {
child: const Text('Settings'),
),
Consumer(
builder: (context, childRef, child) {
final browserExtensions = childRef.watch(
builder: (context, ref, child) {
final browserExtensions = ref.watch(
webExtensionsStateProvider(
WebExtensionActionType.browser,
).select((value) => value.values.toList()),
@@ -396,24 +383,18 @@ class BrowserScreen extends HookConsumerWidget {
(extension) => MenuItemButton(
onPressed: () async {
//Use parents .ref because after onPressed this consumer gets disposed already
await ref
.read(addonServiceProvider)
.invokeAddonAction(
extension.extensionId,
WebExtensionActionType.browser,
);
await addonService.invokeAddonAction(
extension.extensionId,
WebExtensionActionType.browser,
);
},
leadingIcon: RawImage(
image: extension.icon?.value,
width: 24,
height: 24,
),
leadingIcon: ExtensionBadgeIcon(extension),
child: Text(extension.title ?? ''),
),
),
MenuItemButton(
onPressed: () async {
await childRef
await ref
.read(addonServiceProvider)
.startAddonManagerActivity();
},
@@ -530,7 +511,8 @@ class BrowserScreen extends HookConsumerWidget {
MenuItemButton(
onPressed: () async {
final controller = ref.read(
tabSessionProvider(selectedTabId).notifier,
tabSessionProvider(tabId: selectedTabId)
.notifier,
);
await controller.reload();
@@ -556,7 +538,7 @@ class BrowserScreen extends HookConsumerWidget {
onPressed: () async {
final controller = ref.read(
tabSessionProvider(
selectedTabId,
tabId: selectedTabId,
).notifier,
);
@@ -588,7 +570,7 @@ class BrowserScreen extends HookConsumerWidget {
? () async {
final controller = ref.read(
tabSessionProvider(
selectedTabId,
tabId: selectedTabId,
).notifier,
);
@@ -664,7 +646,7 @@ class BrowserScreen extends HookConsumerWidget {
lastBackButtonPress.value = null;
final controller = ref.read(
tabSessionProvider(selectedTabId).notifier,
tabSessionProvider(tabId: selectedTabId).notifier,
);
await controller.goBack();
@@ -7,6 +7,7 @@ import 'package:lensai/core/logger.dart';
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';
class BrowserView extends StatefulHookConsumerWidget {
final Duration screenshotPeriod;
@@ -84,6 +85,8 @@ class _BrowserViewState extends ConsumerState<BrowserView>
WidgetsBinding.instance.addObserver(this);
//Initialize and register dependencies
ref.listenManual(tabRepositoryProvider, (previous, next) {});
ref.listenManual(
selectionActionServiceProvider,
(previous, next) {},
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:lensai/features/geckoview/domain/entities/web_extension_state.dart';
class ExtensionBadgeIcon extends StatelessWidget {
final WebExtensionState state;
const ExtensionBadgeIcon(this.state);
@override
Widget build(BuildContext context) {
final hasBadge = state.badgeText?.isNotEmpty ?? false;
return Badge(
isLabelVisible: hasBadge,
label: hasBadge ? Text(state.badgeText!) : null,
textColor: state.badgeTextColor,
backgroundColor: state.badgeBackgroundColor,
child: RawImage(
image: state.icon?.value,
width: 24,
height: 24,
),
);
}
}
@@ -67,7 +67,31 @@ class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate {
),
],
),
ContainerChips(),
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();
},
);
},
),
),
const SizedBox(height: 8),
],
),
@@ -1,7 +1,11 @@
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';
import 'package:lexo_rank/lexo_rank.dart';
part 'tab.g.dart';
@@ -10,6 +14,14 @@ part 'tab.g.dart';
class TabDao extends DatabaseAccessor<TabDatabase> with _$TabDaoMixin {
TabDao(super.db);
UpdateStatement<Tab, TabData> _updateByIdStatement(String id) =>
db.tab.update()..where((t) => t.id.equals(id));
Selectable<TabData> allTabData() => db.tab.select();
SingleOrNullSelectable<TabData> tabDataById(String id) =>
db.tab.select()..where((t) => t.id.equals(id));
Selectable<String> containerTabIds(String? containerId) {
final query = selectOnly(db.tab)
..addColumns([db.tab.id])
@@ -74,7 +86,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with _$TabDaoMixin {
String id, {
required String? containerId,
}) {
final statement = db.tab.update()..where((t) => t.id.equals(id));
final statement = _updateByIdStatement(id);
return statement.write(
TabCompanion(containerId: Value(containerId)),
);
@@ -84,7 +96,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with _$TabDaoMixin {
String id, {
required String orderKey,
}) {
final statement = db.tab.update()..where((t) => t.id.equals(id));
final statement = _updateByIdStatement(id);
return statement.write(
TabCompanion(orderKey: Value(orderKey)),
);
@@ -94,12 +106,71 @@ class TabDao extends DatabaseAccessor<TabDatabase> with _$TabDaoMixin {
String id, {
required DateTime timestamp,
}) {
final statement = db.tab.update()..where((t) => t.id.equals(id));
final statement = _updateByIdStatement(id);
return statement.write(
TabCompanion(timestamp: Value(timestamp)),
);
}
Future<void> updateTab(
String id, {
Value<String?> url = const Value.absent(),
Value<String?> title = const Value.absent(),
Value<String?> extractedContentMarkdown = const Value.absent(),
Value<String?> extractedContentPlain = const Value.absent(),
Value<String?> fullContentMarkdown = const Value.absent(),
Value<String?> fullContentPlain = const Value.absent(),
}) async {
final doUpdate = url != const Value.absent() ||
title != const Value.absent() ||
extractedContentMarkdown != const Value.absent() ||
extractedContentPlain != const Value.absent() ||
fullContentMarkdown != const Value.absent() ||
fullContentPlain != const Value.absent();
if (doUpdate) {
final statement = _updateByIdStatement(id);
await statement.write(
TabCompanion(
url: url,
title: title,
extractedContentMarkdown: extractedContentMarkdown,
extractedContentPlain: extractedContentPlain,
fullContentMarkdown: fullContentMarkdown,
fullContentPlain: fullContentPlain,
),
);
}
}
Future<void> updateTabs(
Map<String, TabState>? previous,
Map<String, TabState> next,
) async {
await batch((batch) async {
for (final state in next.values) {
final previousState = previous?[state.id];
if (previousState == null ||
previousState.url != state.url ||
previousState.title != state.title) {
batch.update(
db.tab,
TabCompanion(
url: (previousState?.url != state.url)
? Value(state.url.toString())
: const Value.absent(),
title: (previousState?.title != state.title)
? Value(state.title)
: const Value.absent(),
),
where: (t) => t.id.equals(state.id),
);
}
}
});
}
Future<void> syncTabs({required List<String> retainTabIds}) async {
return db.transaction(() async {
await (db.tab.delete()..where((t) => t.id.isNotIn(retainTabIds))).go();
@@ -125,4 +196,30 @@ class TabDao extends DatabaseAccessor<TabDatabase> with _$TabDaoMixin {
);
});
}
Selectable<TabQueryResult> queryTabs({
required String matchPrefix,
required String matchSuffix,
required String ellipsis,
required int snippetLength,
required String searchString,
}) {
final ftsQuery = db.buildFtsQuery(searchString);
if (ftsQuery.isNotEmpty) {
return db.queryTabsFullContent(
query: db.buildFtsQuery(searchString),
snippetLength: snippetLength,
beforeMatch: matchPrefix,
afterMatch: matchSuffix,
ellipsis: ellipsis,
);
} else {
return db.queryTabsBasic(
query: db.buildLikeQuery(searchString),
beforeMatch: matchPrefix,
afterMatch: matchSuffix,
);
}
}
}
@@ -6,6 +6,10 @@ import 'package:lensai/data/database/converters/icon_data.dart';
import 'package:lensai/features/geckoview/features/tabs/data/database/daos/container.dart';
import 'package:lensai/features/geckoview/features/tabs/data/database/daos/tab.dart';
import 'package:lensai/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:lensai/features/geckoview/features/tabs/data/models/tab_query_result.dart';
import 'package:lensai/features/search/domain/entities/abstract/i_query_builder.dart';
import 'package:lensai/features/search/domain/fts_tokenizer.dart';
import 'package:lensai/features/search/domain/unix_tokenizer.dart';
part 'database.g.dart';
@@ -13,10 +17,40 @@ part 'database.g.dart';
include: {'database.drift'},
daos: [ContainerDao, TabDao],
)
class TabDatabase extends _$TabDatabase {
class TabDatabase extends _$TabDatabase implements IQueryBuilder {
@override
final int schemaVersion = 2;
@override
final int ftsTokenLimit = 10;
@override
final int ftsMinTokenLength = 3;
@override
String buildFtsQuery(String input) {
final ftsQueryBuilder = TrigramQueryBuilder.tokenize(
input: input,
minTokenLength: ftsMinTokenLength,
tokenLimit: ftsTokenLimit,
);
if (ftsQueryBuilder.hasTokens) {
return ftsQueryBuilder.build();
}
return '';
}
String buildLikeQuery(String input) {
final likeQueryBuilder = UnixLikeQueryBuilder.tokenize(
input: input,
minTokenLength: 1,
tokenLimit: 5,
);
return likeQueryBuilder.build();
}
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
@@ -1,6 +1,7 @@
import 'package:lensai/data/database/converters/color.dart';
import 'package:lensai/data/database/converters/icon_data.dart';
import 'package:lensai/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:lensai/features/geckoview/features/tabs/data/models/tab_query_result.dart';
CREATE TABLE container (
id TEXT PRIMARY KEY NOT NULL,
@@ -14,9 +15,45 @@ CREATE TABLE tab (
id TEXT PRIMARY KEY NOT NULL,
container_id TEXT REFERENCES container (id) ON DELETE CASCADE,
order_key TEXT NOT NULL,
url TEXT,
title TEXT,
extracted_content_markdown TEXT,
extracted_content_plain TEXT,
full_content_markdown TEXT,
full_content_plain TEXT,
timestamp DATETIME NOT NULL
);
CREATE VIRTUAL TABLE tab_fts
USING fts5(
title,
url,
extracted_content_plain,
full_content_plain,
content=tab,
tokenize="trigram"
);
-- Triggers to keep the FTS index up to date.
CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN
INSERT INTO
tab_fts(rowid, title, url, extracted_content_plain, full_content_plain)
VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);
END;
CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN
INSERT INTO
tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain)
VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);
END;
CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN
INSERT INTO
tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain)
VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);
INSERT INTO
tab_fts(rowid, title, url, extracted_content_plain, full_content_plain)
VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);
END;
containersWithCount WITH ContainerDataWithCount:
SELECT
container.*,
@@ -66,4 +103,53 @@ orderKeyAfterTab(:tab_id AS TEXT, :container_id AS TEXT OR NULL):
)
SELECT lexo_rank_reorder_after(order_key, next_order_key)
FROM ordered_table
WHERE id = :tab_id;
WHERE id = :tab_id;
queryTabsBasic WITH TabQueryResult:
WITH weights AS (
SELECT
-- Customize these weights (higher = more important)
10.0 as title_weight, -- Title matches are most important
5.0 as url_weight -- URL matches are quite important
)
SELECT
t.id,
highlight(tab_fts, 0, :beforeMatch, :afterMatch) AS title,
highlight(tab_fts, 1, :beforeMatch, :afterMatch) AS url,
bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank
FROM tab_fts fts
INNER JOIN
tab t ON t.rowid = fts.rowid
CROSS JOIN weights
WHERE
fts.title LIKE :query OR
fts.url LIKE :query
ORDER BY
weighted_rank ASC,
t.timestamp DESC;
queryTabsFullContent WITH TabQueryResult:
WITH weights AS (
SELECT
-- Customize these weights (higher = more important)
10.0 as title_weight, -- Title matches are most important
5.0 as url_weight, -- URL matches are quite important
3.0 as extracted_weight, -- Extracted content matches
1.0 as full_weight -- Full content matches less important
)
SELECT
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,
snippet(tab_fts, 3, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS full_content,
(
bm25(tab_fts, weights.title_weight, weights.url_weight,
weights.extracted_weight, weights.full_weight)
) AS weighted_rank
FROM tab_fts(:query) fts
INNER JOIN
tab t ON t.rowid = fts.rowid
CROSS JOIN weights
ORDER BY
weighted_rank ASC,
t.timestamp DESC;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
class TabQueryResult {
final String title;
final String url;
final String? extractedContent;
final String? fullContent;
final double weightedRank;
TabQueryResult({
required this.title,
required this.url,
this.extractedContent,
this.fullContent,
required this.weightedRank,
});
}
@@ -5,6 +5,7 @@ import 'package:lensai/features/geckoview/features/tabs/data/models/container_da
import 'package:lensai/features/geckoview/features/tabs/data/providers.dart';
import 'package:lensai/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:lensai/features/geckoview/features/tabs/utils/color_palette.dart';
import 'package:lensai/features/search/util/tokenized_filter.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -35,6 +36,28 @@ Stream<List<ContainerDataWithCount>> containersWithCount(
return db.containersWithCount().watch();
}
@Riverpod()
AsyncValue<List<ContainerDataWithCount>> filteredContainersWithCount(
Ref ref,
String? searchText,
) {
return ref.watch(
containersWithCountProvider.select((value) {
if (searchText?.isEmpty ?? true) {
return value;
}
return value.whenData(
(cb) => TokenizedFilter(
items: cb,
toString: (item) => item.name,
query: searchText!,
).filtered,
);
}),
);
}
@Riverpod()
Stream<String?> tabContainerId(Ref ref, String tabId) {
final db = ref.watch(tabDatabaseProvider);
@@ -45,7 +45,8 @@ final containersWithCountProvider =
// ignore: unused_element
typedef ContainersWithCountRef
= AutoDisposeStreamProviderRef<List<ContainerDataWithCount>>;
String _$tabContainerIdHash() => r'726ddd000d23935a2c8ae39c7957412fab16016d';
String _$filteredContainersWithCountHash() =>
r'3a7284df29182a32f9b7831867a3a0b3e171b382';
/// Copied from Dart SDK
class _SystemHash {
@@ -68,6 +69,145 @@ class _SystemHash {
}
}
/// See also [filteredContainersWithCount].
@ProviderFor(filteredContainersWithCount)
const filteredContainersWithCountProvider = FilteredContainersWithCountFamily();
/// See also [filteredContainersWithCount].
class FilteredContainersWithCountFamily
extends Family<AsyncValue<List<ContainerDataWithCount>>> {
/// See also [filteredContainersWithCount].
const FilteredContainersWithCountFamily();
/// See also [filteredContainersWithCount].
FilteredContainersWithCountProvider call(
String? searchText,
) {
return FilteredContainersWithCountProvider(
searchText,
);
}
@override
FilteredContainersWithCountProvider getProviderOverride(
covariant FilteredContainersWithCountProvider provider,
) {
return call(
provider.searchText,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'filteredContainersWithCountProvider';
}
/// See also [filteredContainersWithCount].
class FilteredContainersWithCountProvider
extends AutoDisposeProvider<AsyncValue<List<ContainerDataWithCount>>> {
/// See also [filteredContainersWithCount].
FilteredContainersWithCountProvider(
String? searchText,
) : this._internal(
(ref) => filteredContainersWithCount(
ref as FilteredContainersWithCountRef,
searchText,
),
from: filteredContainersWithCountProvider,
name: r'filteredContainersWithCountProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$filteredContainersWithCountHash,
dependencies: FilteredContainersWithCountFamily._dependencies,
allTransitiveDependencies:
FilteredContainersWithCountFamily._allTransitiveDependencies,
searchText: searchText,
);
FilteredContainersWithCountProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.searchText,
}) : super.internal();
final String? searchText;
@override
Override overrideWith(
AsyncValue<List<ContainerDataWithCount>> Function(
FilteredContainersWithCountRef provider)
create,
) {
return ProviderOverride(
origin: this,
override: FilteredContainersWithCountProvider._internal(
(ref) => create(ref as FilteredContainersWithCountRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
searchText: searchText,
),
);
}
@override
AutoDisposeProviderElement<AsyncValue<List<ContainerDataWithCount>>>
createElement() {
return _FilteredContainersWithCountProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is FilteredContainersWithCountProvider &&
other.searchText == searchText;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, searchText.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin FilteredContainersWithCountRef
on AutoDisposeProviderRef<AsyncValue<List<ContainerDataWithCount>>> {
/// The parameter `searchText` of this provider.
String? get searchText;
}
class _FilteredContainersWithCountProviderElement
extends AutoDisposeProviderElement<AsyncValue<List<ContainerDataWithCount>>>
with FilteredContainersWithCountRef {
_FilteredContainersWithCountProviderElement(super.provider);
@override
String? get searchText =>
(origin as FilteredContainersWithCountProvider).searchText;
}
String _$tabContainerIdHash() => r'726ddd000d23935a2c8ae39c7957412fab16016d';
/// See also [tabContainerId].
@ProviderFor(tabContainerId)
const tabContainerIdProvider = TabContainerIdFamily();
@@ -0,0 +1,42 @@
import 'dart:async';
import 'package:lensai/features/geckoview/features/tabs/data/database/database.dart';
import 'package:lensai/features/geckoview/features/tabs/data/models/tab_query_result.dart';
import 'package:lensai/features/geckoview/features/tabs/data/providers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'tab_search.g.dart';
@Riverpod()
class TabSearchRepository extends _$TabSearchRepository {
late TabDatabase _db;
Future<void> addQuery(
String input, {
int snippetLength = 120,
String matchPrefix = '***',
String matchSuffix = '***',
String ellipsis = '',
}) async {
if (input.isNotEmpty) {
state = await AsyncValue.guard(
() => _db.tabDao
.queryTabs(
matchPrefix: matchPrefix,
matchSuffix: matchSuffix,
ellipsis: ellipsis,
snippetLength: snippetLength,
searchString: input,
)
.get(),
);
}
}
@override
Future<List<TabQueryResult>> build() {
_db = ref.watch(tabDatabaseProvider);
return Future.value([]);
}
}
@@ -1,27 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'autosuggest.dart';
part of 'tab_search.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$autosuggestRepositoryHash() =>
r'48d6bf45d648871f8570edfe1e0b394019084695';
String _$tabSearchRepositoryHash() =>
r'211ca4d7e9a744b5f8a32970ba3a6df721fc05e1';
/// See also [AutosuggestRepository].
@ProviderFor(AutosuggestRepository)
final autosuggestRepositoryProvider =
NotifierProvider<AutosuggestRepository, Raw<Stream<List<String>>>>.internal(
AutosuggestRepository.new,
name: r'autosuggestRepositoryProvider',
/// See also [TabSearchRepository].
@ProviderFor(TabSearchRepository)
final tabSearchRepositoryProvider = AutoDisposeAsyncNotifierProvider<
TabSearchRepository, List<TabQueryResult>>.internal(
TabSearchRepository.new,
name: r'tabSearchRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$autosuggestRepositoryHash,
: _$tabSearchRepositoryHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AutosuggestRepository = Notifier<Raw<Stream<List<String>>>>;
typedef _$TabSearchRepository = AutoDisposeAsyncNotifier<List<TabQueryResult>>;
// 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
@@ -1,25 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
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/tabs/data/models/container_data.dart';
import 'package:lensai/features/geckoview/features/tabs/domain/providers.dart';
import 'package:lensai/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:lensai/presentation/widgets/selectable_chips.dart';
class ContainerChips extends HookConsumerWidget {
final bool displayMenu;
final ContainerData? selectedContainer;
final void Function(ContainerDataWithCount)? onSelected;
final void Function(ContainerDataWithCount)? onDeleted;
final TextEditingController? searchTextController;
const ContainerChips({
required this.selectedContainer,
required this.onSelected,
required this.onDeleted,
this.searchTextController,
this.displayMenu = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final containersAsync = ref.watch(containersWithCountProvider);
final selectedContainer = ref.watch(
selectedContainerDataProvider.select((value) => value.valueOrNull),
final searchText = useListenableSelector(
searchTextController,
() => searchTextController?.text,
);
final containersAsync =
ref.watch(filteredContainersWithCountProvider(searchText));
return containersAsync.when(
data: (availableContainers) => SizedBox(
height: 48,
child: Row(
children: [
const SizedBox(width: 16),
if (selectedContainer != null || availableContainers.isNotEmpty)
Expanded(
child: SelectableChips(
@@ -38,34 +57,30 @@ class ContainerChips extends HookConsumerWidget {
itemBadgeCount: (container) => container.tabCount,
availableItems: availableContainers,
selectedItem: selectedContainer,
onSelected: (container) {
ref
.read(selectedContainerProvider.notifier)
.setContainerId(container.id);
},
onDeleted: (container) async {
ref
.read(selectedContainerProvider.notifier)
.clearContainer();
},
onSelected: onSelected,
onDeleted: onDeleted,
),
)
else
Expanded(
child: Text(
"Press '>' to manage Containers.",
style: TextStyle(
color: Theme.of(context).hintColor,
fontStyle: FontStyle.italic,
Visibility(
visible: displayMenu,
child: Expanded(
child: Text(
"Press '>' to manage Containers.",
style: TextStyle(
color: Theme.of(context).hintColor,
fontStyle: FontStyle.italic,
),
),
),
),
IconButton(
onPressed: () async {
await context.push(ContainerListRoute().location);
},
icon: const Icon(Icons.chevron_right),
),
if (displayMenu)
IconButton(
onPressed: () async {
await context.push(ContainerListRoute().location);
},
icon: const Icon(Icons.chevron_right),
),
],
),
),
@@ -3,12 +3,14 @@ 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/search/domain/entities/abstract/i_search_suggestion_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'autosuggest.g.dart';
@Riverpod(keepAlive: true)
class KagiAutosuggestService extends _$KagiAutosuggestService {
class KagiAutosuggestService extends _$KagiAutosuggestService
implements ISearchSuggestionProvider {
static final _baseUrl = Uri.https('kagi.com', 'api/autosuggest');
late http.Client _client;
@@ -18,6 +20,7 @@ class KagiAutosuggestService extends _$KagiAutosuggestService {
_client = http.Client();
}
@override
Future<Result<List<String>>> getSuggestions(String query) async {
return Result.fromAsync(
() async {
@@ -7,7 +7,7 @@ part of 'autosuggest.dart';
// **************************************************************************
String _$kagiAutosuggestServiceHash() =>
r'843b0d84b1dde2d903412a123c8c10d26e8036fd';
r'e1cb4ef730bcf5c4bf4c77617bb12a22228af576';
/// See also [KagiAutosuggestService].
@ProviderFor(KagiAutosuggestService)
@@ -1,26 +1,28 @@
import 'dart:async';
import 'package:lensai/features/kagi/data/services/autosuggest.dart';
import 'package:lensai/features/search/domain/entities/abstract/i_search_suggestion_provider.dart';
import 'package:lensai/utils/lru_cache.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:rxdart/rxdart.dart';
part 'autosuggest.g.dart';
part 'search_suggestions.g.dart';
@Riverpod(keepAlive: true)
class AutosuggestRepository extends _$AutosuggestRepository {
class SearchSuggestionsRepository extends _$SearchSuggestionsRepository {
final LRUCache<String, List<String>> _cache;
late StreamController<String> _queryStreamController;
AutosuggestRepository() : _cache = LRUCache(100);
SearchSuggestionsRepository() : _cache = LRUCache(100);
void addQuery(String query) {
_queryStreamController.add(query);
}
@override
Raw<Stream<List<String>>> build() {
Raw<Stream<List<String>>> build(
ISearchSuggestionProvider suggestionsProvider,
) {
_queryStreamController = StreamController();
ref.onDispose(() async {
await _queryStreamController.close();
@@ -39,8 +41,7 @@ class AutosuggestRepository extends _$AutosuggestRepository {
return Stream.value(cached);
}
return ref
.read(kagiAutosuggestServiceProvider.notifier)
return suggestionsProvider
// ignore: discarded_futures
.getSuggestions(query)
// ignore: discarded_futures
@@ -0,0 +1,182 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_suggestions.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$searchSuggestionsRepositoryHash() =>
r'bc6dc862ae7d887b404972abbbee3c1b27a64d2e';
/// 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));
}
}
abstract class _$SearchSuggestionsRepository
extends BuildlessNotifier<Raw<Stream<List<String>>>> {
late final ISearchSuggestionProvider suggestionsProvider;
Raw<Stream<List<String>>> build(
ISearchSuggestionProvider suggestionsProvider,
);
}
/// See also [SearchSuggestionsRepository].
@ProviderFor(SearchSuggestionsRepository)
const searchSuggestionsRepositoryProvider = SearchSuggestionsRepositoryFamily();
/// See also [SearchSuggestionsRepository].
class SearchSuggestionsRepositoryFamily
extends Family<Raw<Stream<List<String>>>> {
/// See also [SearchSuggestionsRepository].
const SearchSuggestionsRepositoryFamily();
/// See also [SearchSuggestionsRepository].
SearchSuggestionsRepositoryProvider call(
ISearchSuggestionProvider suggestionsProvider,
) {
return SearchSuggestionsRepositoryProvider(
suggestionsProvider,
);
}
@override
SearchSuggestionsRepositoryProvider getProviderOverride(
covariant SearchSuggestionsRepositoryProvider provider,
) {
return call(
provider.suggestionsProvider,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'searchSuggestionsRepositoryProvider';
}
/// See also [SearchSuggestionsRepository].
class SearchSuggestionsRepositoryProvider extends NotifierProviderImpl<
SearchSuggestionsRepository, Raw<Stream<List<String>>>> {
/// See also [SearchSuggestionsRepository].
SearchSuggestionsRepositoryProvider(
ISearchSuggestionProvider suggestionsProvider,
) : this._internal(
() => SearchSuggestionsRepository()
..suggestionsProvider = suggestionsProvider,
from: searchSuggestionsRepositoryProvider,
name: r'searchSuggestionsRepositoryProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$searchSuggestionsRepositoryHash,
dependencies: SearchSuggestionsRepositoryFamily._dependencies,
allTransitiveDependencies:
SearchSuggestionsRepositoryFamily._allTransitiveDependencies,
suggestionsProvider: suggestionsProvider,
);
SearchSuggestionsRepositoryProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.suggestionsProvider,
}) : super.internal();
final ISearchSuggestionProvider suggestionsProvider;
@override
Raw<Stream<List<String>>> runNotifierBuild(
covariant SearchSuggestionsRepository notifier,
) {
return notifier.build(
suggestionsProvider,
);
}
@override
Override overrideWith(SearchSuggestionsRepository Function() create) {
return ProviderOverride(
origin: this,
override: SearchSuggestionsRepositoryProvider._internal(
() => create()..suggestionsProvider = suggestionsProvider,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
suggestionsProvider: suggestionsProvider,
),
);
}
@override
NotifierProviderElement<SearchSuggestionsRepository,
Raw<Stream<List<String>>>> createElement() {
return _SearchSuggestionsRepositoryProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SearchSuggestionsRepositoryProvider &&
other.suggestionsProvider == suggestionsProvider;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, suggestionsProvider.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SearchSuggestionsRepositoryRef
on NotifierProviderRef<Raw<Stream<List<String>>>> {
/// The parameter `suggestionsProvider` of this provider.
ISearchSuggestionProvider get suggestionsProvider;
}
class _SearchSuggestionsRepositoryProviderElement
extends NotifierProviderElement<SearchSuggestionsRepository,
Raw<Stream<List<String>>>> with SearchSuggestionsRepositoryRef {
_SearchSuggestionsRepositoryProviderElement(super.provider);
@override
ISearchSuggestionProvider get suggestionsProvider =>
(origin as SearchSuggestionsRepositoryProvider).suggestionsProvider;
}
// 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
@@ -4,7 +4,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
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.dart';
import 'package:lensai/features/bangs/domain/providers/bangs.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';
@@ -32,8 +32,8 @@ class SearchTab extends HookConsumerWidget {
final selectedBang = ref
.watch(selectedBangDataProvider().select((value) => value.valueOrNull));
final defaultSearchBang = ref
.watch(kagiSearchBangDataProvider.select((value) => value.valueOrNull));
final defaultSearchBang = ref.watch(
defaultSearchBangDataProvider.select((value) => value.valueOrNull));
final activeBang = selectedBang ?? defaultSearchBang;
@@ -2,5 +2,5 @@ abstract interface class IQueryBuilder {
int get ftsTokenLimit;
int get ftsMinTokenLength;
String buildQuery(String input);
String buildFtsQuery(String input);
}
@@ -0,0 +1,5 @@
import 'package:exceptions/exceptions.dart';
abstract interface class ISearchSuggestionProvider {
Future<Result<List<String>>> getSuggestions(String query);
}
@@ -1,12 +1,12 @@
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:lensai/features/query/domain/entities/abstract/i_query_builder.dart';
import 'package:lensai/features/query/domain/entities/bareword.dart';
import 'package:lensai/features/search/domain/entities/abstract/i_query_builder.dart';
import 'package:lensai/features/search/domain/entities/bareword.dart';
typedef _Phrase = List<Bareword>;
sealed class QueryBuilder {
sealed class FtsQueryBuilder {
///Matching quoted strings like "xda bda" (group 1), as well as strings delimetered by
///a whitespace (group 2)
static final _tokenizePattern = RegExp('"([^"]+)"|([^ ]+)');
@@ -26,7 +26,9 @@ sealed class QueryBuilder {
late final _Phrase _tokens;
QueryBuilder.tokenize({
bool get hasTokens => _tokens.isNotEmpty;
FtsQueryBuilder.tokenize({
required String input,
required int minTokenLength,
required int tokenLimit,
@@ -174,7 +176,7 @@ sealed class QueryBuilder {
String build();
}
final class PrefixQueryBuilder extends QueryBuilder {
final class PrefixQueryBuilder extends FtsQueryBuilder {
PrefixQueryBuilder.tokenize({
required super.input,
required super.minTokenLength,
@@ -197,7 +199,7 @@ final class PrefixQueryBuilder extends QueryBuilder {
}
}
final class TrigramQueryBuilder extends QueryBuilder {
final class TrigramQueryBuilder extends FtsQueryBuilder {
TrigramQueryBuilder.tokenize({
required super.input,
required super.minTokenLength,
@@ -216,7 +218,7 @@ final class TrigramQueryBuilder extends QueryBuilder {
mixin PrefixQueryBuilderMixin implements IQueryBuilder {
@override
String buildQuery(String input) {
String buildFtsQuery(String input) {
final queryBuilder = PrefixQueryBuilder.tokenize(
input: input,
minTokenLength: ftsMinTokenLength,
@@ -229,7 +231,7 @@ mixin PrefixQueryBuilderMixin implements IQueryBuilder {
mixin TrigramQueryBuilderMixin implements IQueryBuilder {
@override
String buildQuery(String input) {
String buildFtsQuery(String input) {
final queryBuilder = TrigramQueryBuilder.tokenize(
input: input,
minTokenLength: ftsMinTokenLength,
@@ -0,0 +1,35 @@
import 'package:lensai/features/kagi/data/services/autosuggest.dart';
import 'package:lensai/features/kagi/domain/repositories/search_suggestions.dart';
import 'package:lensai/features/search/domain/entities/abstract/i_search_suggestion_provider.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'search_suggestions.g.dart';
@Riverpod(keepAlive: true)
ISearchSuggestionProvider defaultSearchSuggestions(Ref ref) {
return ref.watch(kagiAutosuggestServiceProvider.notifier);
}
@Riverpod(keepAlive: true)
class SearchSuggestions extends _$SearchSuggestions {
late void Function(String query) _addQueryBinding;
void addQuery(String query) => _addQueryBinding(query);
@override
Raw<Stream<List<String>>> build({
ISearchSuggestionProvider? suggestionsProvider,
}) {
final defaultProvider = ref.watch(defaultSearchSuggestionsProvider);
final resolvedProvider = suggestionsProvider ?? defaultProvider;
_addQueryBinding = ref
.watch(searchSuggestionsRepositoryProvider(resolvedProvider).notifier)
.addQuery;
return ref.watch(
searchSuggestionsRepositoryProvider(resolvedProvider),
);
}
}
@@ -0,0 +1,196 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_suggestions.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$defaultSearchSuggestionsHash() =>
r'56514064baa64acf0a28ec34cfad957dd863538b';
/// See also [defaultSearchSuggestions].
@ProviderFor(defaultSearchSuggestions)
final defaultSearchSuggestionsProvider =
Provider<ISearchSuggestionProvider>.internal(
defaultSearchSuggestions,
name: r'defaultSearchSuggestionsProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$defaultSearchSuggestionsHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef DefaultSearchSuggestionsRef = ProviderRef<ISearchSuggestionProvider>;
String _$searchSuggestionsHash() => r'ecc378aca325cc6759292a06d8d4d5ace910eb7f';
/// 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));
}
}
abstract class _$SearchSuggestions
extends BuildlessNotifier<Raw<Stream<List<String>>>> {
late final ISearchSuggestionProvider? suggestionsProvider;
Raw<Stream<List<String>>> build({
ISearchSuggestionProvider? suggestionsProvider,
});
}
/// See also [SearchSuggestions].
@ProviderFor(SearchSuggestions)
const searchSuggestionsProvider = SearchSuggestionsFamily();
/// See also [SearchSuggestions].
class SearchSuggestionsFamily extends Family<Raw<Stream<List<String>>>> {
/// See also [SearchSuggestions].
const SearchSuggestionsFamily();
/// See also [SearchSuggestions].
SearchSuggestionsProvider call({
ISearchSuggestionProvider? suggestionsProvider,
}) {
return SearchSuggestionsProvider(
suggestionsProvider: suggestionsProvider,
);
}
@override
SearchSuggestionsProvider getProviderOverride(
covariant SearchSuggestionsProvider provider,
) {
return call(
suggestionsProvider: provider.suggestionsProvider,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'searchSuggestionsProvider';
}
/// See also [SearchSuggestions].
class SearchSuggestionsProvider
extends NotifierProviderImpl<SearchSuggestions, Raw<Stream<List<String>>>> {
/// See also [SearchSuggestions].
SearchSuggestionsProvider({
ISearchSuggestionProvider? suggestionsProvider,
}) : this._internal(
() => SearchSuggestions()..suggestionsProvider = suggestionsProvider,
from: searchSuggestionsProvider,
name: r'searchSuggestionsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$searchSuggestionsHash,
dependencies: SearchSuggestionsFamily._dependencies,
allTransitiveDependencies:
SearchSuggestionsFamily._allTransitiveDependencies,
suggestionsProvider: suggestionsProvider,
);
SearchSuggestionsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.suggestionsProvider,
}) : super.internal();
final ISearchSuggestionProvider? suggestionsProvider;
@override
Raw<Stream<List<String>>> runNotifierBuild(
covariant SearchSuggestions notifier,
) {
return notifier.build(
suggestionsProvider: suggestionsProvider,
);
}
@override
Override overrideWith(SearchSuggestions Function() create) {
return ProviderOverride(
origin: this,
override: SearchSuggestionsProvider._internal(
() => create()..suggestionsProvider = suggestionsProvider,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
suggestionsProvider: suggestionsProvider,
),
);
}
@override
NotifierProviderElement<SearchSuggestions, Raw<Stream<List<String>>>>
createElement() {
return _SearchSuggestionsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SearchSuggestionsProvider &&
other.suggestionsProvider == suggestionsProvider;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, suggestionsProvider.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SearchSuggestionsRef on NotifierProviderRef<Raw<Stream<List<String>>>> {
/// The parameter `suggestionsProvider` of this provider.
ISearchSuggestionProvider? get suggestionsProvider;
}
class _SearchSuggestionsProviderElement extends NotifierProviderElement<
SearchSuggestions, Raw<Stream<List<String>>>> with SearchSuggestionsRef {
_SearchSuggestionsProviderElement(super.provider);
@override
ISearchSuggestionProvider? get suggestionsProvider =>
(origin as SearchSuggestionsProvider).suggestionsProvider;
}
// 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
@@ -0,0 +1,68 @@
import 'package:lensai/features/search/domain/entities/bareword.dart';
typedef _Phrase = List<Bareword>;
sealed class UnixTokenizer {
///Matching quoted strings like "xda bda" (group 1), as well as strings delimetered by
///a whitespace (group 2)
static final _tokenizePattern = RegExp('"([^"]+)"|([^ ]+)');
late final _Phrase _tokens;
bool get hasTokens => _tokens.isNotEmpty;
static void _mergeShortBarewords(
List<Bareword> barewords,
int minTokenLength,
) {
for (var i = 0; i < barewords.length; i++) {
final bareword = barewords[i];
if (i != 0) {
if (bareword.word.length < minTokenLength) {
barewords[i] = barewords[i - 1].join(bareword);
}
}
}
}
UnixTokenizer.tokenize({
required String input,
required int minTokenLength,
required int tokenLimit,
}) {
final matches = _tokenizePattern.allMatches(input);
final barewords = matches
.map((match) {
if (match.group(1) != null) {
return EnclosedBareword(match.group(1)!);
} else {
return SimpleBareword(match.group(2)!);
}
})
.where((token) => token.word.isNotEmpty)
.toList();
//Merge short tokens
_mergeShortBarewords(barewords, minTokenLength);
_tokens = barewords.take(tokenLimit).toList();
}
String build({bool wildcard});
}
final class UnixLikeQueryBuilder extends UnixTokenizer {
UnixLikeQueryBuilder.tokenize({
required super.input,
required super.minTokenLength,
required super.tokenLimit,
}) : super.tokenize();
@override
String build({bool wildcard = true}) {
final joined = _tokens.join('%');
return wildcard ? '%$joined%' : joined;
}
}
@@ -0,0 +1,262 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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/repositories/data.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.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';
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/features/search/domain/providers/search_suggestions.dart';
import 'package:lensai/features/search/presentation/widgets/bang_chips.dart';
import 'package:lensai/features/search/presentation/widgets/search_field.dart';
import 'package:lensai/presentation/hooks/listenable_callback.dart';
import 'package:skeletonizer/skeletonizer.dart';
class SearchScreen extends HookConsumerWidget {
final String? initialSearchText;
const SearchScreen({required this.initialSearchText});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final searchTextController =
useTextEditingController(text: initialSearchText);
useListenableCallback(
searchTextController,
() async {
ref
.read(searchSuggestionsProvider().notifier)
.addQuery(searchTextController.text);
await ref
.read(tabSearchRepositoryProvider.notifier)
.addQuery(searchTextController.text);
},
);
final globalSelectedContainer = ref.watch(
selectedContainerDataProvider.select((value) => value.valueOrNull),
);
final defaultSearchBang = ref.watch(
defaultSearchBangDataProvider.select((value) => value.valueOrNull),
);
final selectedBang = useState<BangData?>(null);
final activeBang = selectedBang.value ?? defaultSearchBang;
final showBangIcon = useState(false);
ref.listen(
selectedBangDataProvider(),
(previous, next) {
if (next.hasValue) {
if ((previous?.hasValue ?? false) && previous!.value != next.value) {
showBangIcon.value = true;
}
selectedBang.value = next.value;
}
},
);
final selectedContainer = useState<ContainerData?>(globalSelectedContainer);
final availableTabs = ref
.watch(
availableTabIdsProvider(selectedContainer.value?.id).select(
(value) => EquatableCollection(value, immutable: true),
),
)
.collection;
return Scaffold(
body: Form(
key: formKey,
child: CustomScrollView(
slivers: [
SliverAppBar(
floating: true,
pinned: true,
automaticallyImplyLeading: false,
title: SearchField(
showBangIcon: showBangIcon.value,
textEditingController: searchTextController,
onFieldSubmitted: (text) {},
activeBang: activeBang,
),
),
const SliverToBoxAdapter(
child: Divider(),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Site Search',
style: Theme.of(context).textTheme.labelSmall,
),
BangChips(
activeBang: activeBang,
onSelected: (bang) {
ref
.read(selectedBangTriggerProvider().notifier)
.setTrigger(bang.trigger);
},
onDeleted: (bang) async {
if (ref.read(selectedBangTriggerProvider()) ==
bang.trigger) {
ref
.read(
selectedBangTriggerProvider().notifier,
)
.clearTrigger();
} else {
final dialogResult = await BangChips.resetBangDialog(
context,
bang.trigger,
);
if (dialogResult == true) {
await ref
.read(bangDataRepositoryProvider.notifier)
.resetFrequency(bang.trigger);
}
}
},
searchTextController: searchTextController,
),
],
),
),
),
HookConsumer(
builder: (context, ref, child) {
final hasSearchText = useListenableSelector(
searchTextController,
() => searchTextController.text.isNotEmpty,
);
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];
return ListTile(
leading: const Icon(Icons.search),
title: Text(suggestion),
onLongPress: () {
searchTextController.text = suggestion;
},
);
},
);
},
),
const SliverToBoxAdapter(
child: Divider(),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tabs',
style: Theme.of(context).textTheme.labelSmall,
),
ContainerChips(
displayMenu: false,
selectedContainer: selectedContainer.value,
onSelected: (container) {
selectedContainer.value = container;
},
onDeleted: (container) {
selectedContainer.value = null;
},
searchTextController: searchTextController,
),
],
),
),
),
Consumer(
builder: (context, ref, child) {
final tabs = ref.watch(tabSearchRepositoryProvider);
return SliverSkeletonizer(
enabled: tabs.isLoading,
child: tabs.when(
data: (data) {
return SliverList.builder(itemBuilder: itemBuilder);
},
error: error,
loading: () => SliverList.builder(
itemCount: tabs.valueOrNull?.length ?? 3,
itemBuilder: (context, index) {
return ListTile()
},),
),
);
},
),
SliverList.builder(
itemCount: availableTabs.length,
itemBuilder: (context, index) {
return Consumer(
key: ValueKey(availableTabs[index]),
builder: (context, ref, child) {
final tab =
ref.watch(tabStateProvider(availableTabs[index]));
if (tab == null) {
return const SizedBox.shrink();
}
return ListTile(
leading: RawImage(
image: tab.icon?.value,
height: 24,
width: 24,
),
title: Text(tab.title),
subtitle: Text(
tab.url.toString(),
softWrap: false,
overflow: TextOverflow.fade,
),
);
},
);
},
),
],
),
),
);
}
}
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
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/data/models/bang_data.dart';
import 'package:lensai/features/bangs/domain/repositories/search.dart';
import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart';
import 'package:lensai/presentation/hooks/listenable_callback.dart';
import 'package:lensai/presentation/widgets/selectable_chips.dart';
class BangChips extends HookConsumerWidget {
final bool displayMenu;
final BangData? activeBang;
final void Function(BangData)? onSelected;
final void Function(BangData)? onDeleted;
final TextEditingController? searchTextController;
const BangChips({
required this.activeBang,
required this.onSelected,
required this.onDeleted,
this.searchTextController,
this.displayMenu = true,
});
static Future<bool?> resetBangDialog(
BuildContext context,
String triggerName,
) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(
'Reset usage frequency of $triggerName?',
),
content: const Text(
'This will remove the Bang from quick select.',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Reset'),
),
],
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final availableBangs = ref.watch(seamlessBangProviderProvider);
useListenableCallback(
searchTextController,
() {
ref
.read(seamlessBangProviderProvider.notifier)
.search(searchTextController!.text);
},
);
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,
),
),
),
),
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),
),
],
),
),
error: (error, stackTrace) => const SizedBox.shrink(),
loading: () => const SizedBox(
height: 48,
width: double.infinity,
),
);
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
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';
class SearchField extends HookConsumerWidget {
final TextEditingController textEditingController;
final FocusNode? focusNode;
final void Function(String)? onFieldSubmitted;
final BangData? activeBang;
final bool showBangIcon;
const SearchField({
super.key,
required this.textEditingController,
required this.onFieldSubmitted,
required this.activeBang,
this.focusNode,
this.showBangIcon = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final incognitoEnabled = ref.watch(
settingsRepositoryProvider.select(
(value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode,
),
);
final hasText = useListenableSelector(
textEditingController,
() => textEditingController.text.isNotEmpty,
);
return TextFormField(
controller: textEditingController,
enableIMEPersonalizedLearning: !incognitoEnabled,
focusNode: focusNode,
decoration: InputDecoration(
border: InputBorder.none,
prefixIcon: (showBangIcon && activeBang != null)
? Padding(
padding: const EdgeInsetsDirectional.all(12.0),
child: BangIcon(activeBang!, iconSize: 24.0),
)
: null,
label: const Text('Search'),
hintText: 'Ask anything...',
floatingLabelBehavior: FloatingLabelBehavior.always,
suffixIcon: hasText
? IconButton(
onPressed: () {
textEditingController.clear();
},
icon: const Icon(Icons.clear),
)
: SpeechToTextButton(
onTextReceived: (data) {
textEditingController.text = data.toString();
},
),
),
// autovalidateMode: AutovalidateMode.onUserInteraction,
// validator: (value) {
// if (value?.isEmpty ?? true) {
// return '';
// }
// return null;
// },
onTapOutside: (focusNode != null)
? (event) {
focusNode!.unfocus();
}
: null,
onFieldSubmitted: onFieldSubmitted,
);
}
}
@@ -0,0 +1,48 @@
typedef ToString<T> = String? Function(T);
class TokenizedFilter<T> {
final Iterable<T> original;
final List<T> filtered;
static List<String> _tokenize(String input) {
return input
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[^\w\s]'), '') // Remove punctuation
.split(RegExp(r'\s+')) // Split on whitespace
.where((word) => word.isNotEmpty)
.toList();
}
static bool _matchTokens(
List<String> queryTokens,
List<String> targetTokens,
) {
return queryTokens.every(
(queryToken) =>
targetTokens.any((targetToken) => targetToken.contains(queryToken)),
);
}
factory TokenizedFilter({
required Iterable<T> items,
required ToString<T> toString,
required String query,
}) {
final queryTokens = _tokenize(query);
final filtered = items.where((item) {
final itemString = toString(item);
if (itemString == null || itemString.isEmpty) {
return false;
}
final itemTokens = _tokenize(itemString);
return _matchTokens(queryTokens, itemTokens);
}).toList();
return TokenizedFilter._(items, filtered);
}
const TokenizedFilter._(this.original, this.filtered);
}
@@ -5,7 +5,7 @@ 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.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';
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/bangs/data/models/bang.dart';
import 'package:lensai/features/bangs/domain/providers.dart';
import 'package:lensai/features/bangs/domain/providers/bangs.dart';
import 'package:lensai/features/bangs/domain/repositories/sync.dart';
import 'package:lensai/features/settings/presentation/widgets/custom_list_tile.dart';
import 'package:lensai/features/settings/presentation/widgets/sync_details_table.dart';
@@ -1,12 +1,13 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
void useListenableCallback(Listenable? listenable, void Function() callback) {
void useListenableCallback(Listenable? listenable, void Function() callback,
[List<Object?>? keys]) {
useEffect(
() {
listenable?.addListener(callback);
return () => listenable?.removeListener(callback);
},
[listenable],
keys ?? [listenable],
);
}
+21
View File
@@ -0,0 +1,21 @@
import 'dart:async';
class Debouncer {
final Duration debounce;
Timer? _timer;
Debouncer(this.debounce);
bool get isDebouncing => _timer?.isActive ?? false;
void eventOccured(void Function() callback) {
_timer?.cancel();
_timer = Timer(debounce, callback);
}
void dispose() {
_timer?.cancel();
_timer = null;
}
}
+2 -2
View File
@@ -34,7 +34,7 @@ class LRUCache<K, V> {
return value;
}
void set(K key, V value) {
V set(K key, V value) {
if (_cache.containsKey(key)) {
_cache.remove(key); // Remove the existing item before updating.
} else if (_cache.length == _capacity) {
@@ -43,6 +43,6 @@ class LRUCache<K, V> {
); // Explicitly remove the least recently used item if at capacity.
}
_cache[key] = value; // Inserting or updating the item.
return _cache[key] = value; // Inserting or updating the item.
}
}
+18 -18
View File
@@ -162,10 +162,10 @@ packages:
dependency: transitive
description:
name: cli_util
sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.1"
version: "0.4.2"
clock:
dependency: transitive
description:
@@ -178,10 +178,10 @@ packages:
dependency: transitive
description:
name: code_builder
sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37
sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e"
url: "https://pub.dev"
source: hosted
version: "4.10.0"
version: "4.10.1"
collection:
dependency: "direct main"
description:
@@ -234,10 +234,10 @@ packages:
dependency: transitive
description:
name: csslib
sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb"
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.0.2"
custom_lint:
dependency: "direct dev"
description:
@@ -290,10 +290,10 @@ packages:
dependency: "direct dev"
description:
name: drift_dev
sha256: "4592cea370f1dcd8afc67987c28402797d9ddb4aa3ae372cb675497c0741816c"
sha256: "623649abe932fc17bd32e578e7e05f7ac5e7dd0b33e6c8669a0634105d1389bf"
url: "https://pub.dev"
source: hosted
version: "2.21.1"
version: "2.21.2"
dynamic_color:
dependency: "direct main"
description:
@@ -544,10 +544,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: "6f1b756f6e863259a99135ff3c95026c3cdca17d10ebef2bba2261a25ddc8bbc"
sha256: ce89c5a993ca5eea74535f798478502c30a625ecb10a1de4d7fef5cd1bcac2a4
url: "https://pub.dev"
source: hosted
version: "14.3.0"
version: "14.4.1"
go_router_builder:
dependency: "direct dev"
description:
@@ -600,10 +600,10 @@ packages:
dependency: "direct main"
description:
name: html
sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a"
sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec"
url: "https://pub.dev"
source: hosted
version: "0.15.4"
version: "0.15.5"
http:
dependency: "direct main"
description:
@@ -817,10 +817,10 @@ packages:
dependency: "direct main"
description:
name: path_provider
sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
@@ -1260,10 +1260,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "0dea215895a4d254401730ca0ba8204b29109a34a99fb06ae559a2b60988d2de"
sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193"
url: "https://pub.dev"
source: hosted
version: "6.3.13"
version: "6.3.14"
url_launcher_ios:
dependency: transitive
description:
@@ -1372,10 +1372,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: e1d0cc62e65dc2561f5071fcbccecf58ff20c344f8f3dc7d4922df372a11df1f
sha256: "84ba388638ed7a8cb3445a320c8273136ab2631cd5f2c57888335504ddab1bc2"
url: "https://pub.dev"
source: hosted
version: "5.7.1"
version: "5.8.0"
xdg_directories:
dependency: transitive
description:
+4 -4
View File
@@ -26,11 +26,11 @@ dependencies:
flutter_pdf_text: ^0.7.0
flutter_secure_storage: ^9.2.2
flutter_sharing_intent: ^1.1.1
go_router: ^14.3.0
go_router: ^14.4.1
google_fonts: ^6.2.1
home_widget: ^0.7.0
hooks_riverpod: ^2.6.1
html: ^0.15.4
html: ^0.15.5
http: ^1.2.2
json_annotation: ^4.9.0
lexo_rank:
@@ -41,7 +41,7 @@ dependencies:
mime: ^2.0.0
package_info_plus: ^8.1.0
path: ^1.9.0
path_provider: ^2.1.4
path_provider: ^2.1.5
reorderable_grid: ^1.0.10
riverpod: ^2.6.1
riverpod_annotation: ^2.6.1
@@ -69,7 +69,7 @@ dev_dependencies:
copy_with_extension_gen: ^5.0.4
custom_lint: ^0.6.8
dependency_validator: ^4.1.1
drift_dev: ^2.21.1
drift_dev: ^2.21.2
fast_equatable_lint: ^0.3.0
flutter_launcher_icons: ^0.14.1
go_router_builder: ^2.7.1