diff --git a/app/lib/core/routing/routes.browser.dart b/app/lib/core/routing/routes.browser.dart index c5942649..573a865f 100644 --- a/app/lib/core/routing/routes.browser.dart +++ b/app/lib/core/routing/routes.browser.dart @@ -36,17 +36,14 @@ class BrowserRoute extends GoRouteData { class WebPageRoute extends GoRouteData { final String url; + final WebPageInfo? $extra; - const WebPageRoute({required this.url}); + const WebPageRoute({required this.url, required this.$extra}); @override Page buildPage(BuildContext context, GoRouterState state) { return DialogPage( - builder: - (_) => WebPageDialog( - url: Uri.parse(url), - precachedInfo: state.extra as WebPageInfo?, - ), + builder: (_) => WebPageDialog(url: Uri.parse(url), precachedInfo: $extra), ); } } @@ -85,33 +82,37 @@ class ContainerListRoute extends GoRouteData { } class ContainerEditRoute extends GoRouteData { + final ContainerData $extra; + + ContainerEditRoute(this.$extra); + @override Widget build(BuildContext context, GoRouterState state) { - return ContainerEditScreen.edit( - initialContainer: state.extra! as ContainerData, - ); + return ContainerEditScreen.edit(initialContainer: $extra); } } class ContainerCreateRoute extends GoRouteData { + final ContainerData $extra; + + ContainerCreateRoute(this.$extra); + @override Widget build(BuildContext context, GoRouterState state) { - return ContainerEditScreen.create( - initialContainer: state.extra! as ContainerData, - ); + return ContainerEditScreen.create(initialContainer: $extra); } } class ContextMenuRoute extends GoRouteData { - const ContextMenuRoute(); + final String $extra; + + const ContextMenuRoute(this.$extra); @override Page buildPage(BuildContext context, GoRouterState state) { return DialogPage( builder: - (_) => ContextMenuDialog( - hitResult: HitResultJson.fromJson(state.extra! as String), - ), + (_) => ContextMenuDialog(hitResult: HitResultJson.fromJson($extra)), ); } } diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index 1434e12f..1f837def 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:lensai/core/routing/widgets/dialog_page.dart'; @@ -22,11 +24,12 @@ import 'package:lensai/features/settings/presentation/screens/web_engine_hardeni import 'package:lensai/features/settings/presentation/screens/web_engine_settings.dart'; import 'package:lensai/features/tor/presentation/screens/tor_proxy.dart'; import 'package:lensai/features/user/presentation/screens/auth.dart'; -import 'package:lensai/features/web_feed/data/database/database.dart'; +import 'package:lensai/features/web_feed/presentation/add_feed_dialog.dart'; import 'package:lensai/features/web_feed/presentation/screens/feed_article.dart'; import 'package:lensai/features/web_feed/presentation/screens/feed_article_list.dart'; import 'package:lensai/features/web_feed/presentation/screens/feed_edit.dart'; import 'package:lensai/features/web_feed/presentation/screens/feed_list.dart'; +import 'package:lensai/features/web_feed/presentation/select_feed_dialog.dart'; part 'routes.g.dart'; part 'routes.settings.dart'; diff --git a/app/lib/core/routing/routes.feeds.dart b/app/lib/core/routing/routes.feeds.dart index ae10051a..241b04cf 100644 --- a/app/lib/core/routing/routes.feeds.dart +++ b/app/lib/core/routing/routes.feeds.dart @@ -4,6 +4,7 @@ part of 'routes.dart'; name: 'FeedListRoute', path: '/feeds', routes: [ + TypedGoRoute(name: 'FeedAddRoute', path: 'add'), TypedGoRoute( name: 'FeedArticleListRoute', path: 'articles/:feedId', @@ -12,7 +13,15 @@ part of 'routes.dart'; name: 'FeedArticleRoute', path: 'article/:articleId', ), - TypedGoRoute(name: 'FeedCreateRoute', path: 'create'), + TypedGoRoute( + name: 'FeedCreateRoute', + path: 'create/:feedId', + ), + TypedGoRoute( + name: 'SelectFeedDialogRoute', + path: 'available/:feedsJson', + ), + TypedGoRoute(name: 'FeedEditRoute', path: 'edit/:feedId'), ], ) class FeedListRoute extends GoRouteData { @@ -23,9 +32,52 @@ class FeedListRoute extends GoRouteData { } class FeedCreateRoute extends GoRouteData { + final Uri feedId; + + FeedCreateRoute({required this.feedId}); + @override Widget build(BuildContext context, GoRouterState state) { - return FeedEditScreen.create(initialFeed: state.extra! as FeedData); + return FeedEditScreen.create(feedId: feedId); + } +} + +class SelectFeedDialogRoute extends GoRouteData { + final String feedsJson; + + const SelectFeedDialogRoute({required this.feedsJson}); + + @override + Page buildPage(BuildContext context, GoRouterState state) { + final feedUris = Set.from( + (jsonDecode(feedsJson) as List).map( + (url) => Uri.parse(url as String), + ), + ); + + return DialogPage(builder: (_) => SelectFeedDialog(feedUris: feedUris)); + } +} + +class FeedEditRoute extends GoRouteData { + final Uri feedId; + + const FeedEditRoute({required this.feedId}); + + @override + Widget build(BuildContext context, GoRouterState state) { + return FeedEditScreen.edit(feedId: feedId); + } +} + +class FeedAddRoute extends GoRouteData { + final Uri? $extra; + + const FeedAddRoute({this.$extra}); + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return DialogPage(builder: (_) => AddFeedDialog(initialUri: $extra)); } } diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index bf86de1a..de900400 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -270,20 +270,24 @@ extension $BrowserRouteExtension on BrowserRoute { } extension $WebPageRouteExtension on WebPageRoute { - static WebPageRoute _fromState(GoRouterState state) => - WebPageRoute(url: state.pathParameters['url']!); + static WebPageRoute _fromState(GoRouterState state) => WebPageRoute( + url: state.pathParameters['url']!, + $extra: state.extra as WebPageInfo?, + ); String get location => GoRouteData.$location('/page/${Uri.encodeComponent(url)}'); - void go(BuildContext context) => context.go(location); + void go(BuildContext context) => context.go(location, extra: $extra); - Future push(BuildContext context) => context.push(location); + Future push(BuildContext context) => + context.push(location, extra: $extra); void pushReplacement(BuildContext context) => - context.pushReplacement(location); + context.pushReplacement(location, extra: $extra); - void replace(BuildContext context) => context.replace(location); + void replace(BuildContext context) => + context.replace(location, extra: $extra); } extension $SearchRouteExtension on SearchRoute { @@ -322,18 +326,20 @@ extension $TorProxyRouteExtension on TorProxyRoute { extension $ContextMenuRouteExtension on ContextMenuRoute { static ContextMenuRoute _fromState(GoRouterState state) => - const ContextMenuRoute(); + ContextMenuRoute(state.extra as String); String get location => GoRouteData.$location('/context_menu'); - void go(BuildContext context) => context.go(location); + void go(BuildContext context) => context.go(location, extra: $extra); - Future push(BuildContext context) => context.push(location); + Future push(BuildContext context) => + context.push(location, extra: $extra); void pushReplacement(BuildContext context) => - context.pushReplacement(location); + context.pushReplacement(location, extra: $extra); - void replace(BuildContext context) => context.replace(location); + void replace(BuildContext context) => + context.replace(location, extra: $extra); } extension $ContainerListRouteExtension on ContainerListRoute { @@ -354,34 +360,38 @@ extension $ContainerListRouteExtension on ContainerListRoute { extension $ContainerCreateRouteExtension on ContainerCreateRoute { static ContainerCreateRoute _fromState(GoRouterState state) => - ContainerCreateRoute(); + ContainerCreateRoute(state.extra as ContainerData); String get location => GoRouteData.$location('/containers/create'); - void go(BuildContext context) => context.go(location); + void go(BuildContext context) => context.go(location, extra: $extra); - Future push(BuildContext context) => context.push(location); + Future push(BuildContext context) => + context.push(location, extra: $extra); void pushReplacement(BuildContext context) => - context.pushReplacement(location); + context.pushReplacement(location, extra: $extra); - void replace(BuildContext context) => context.replace(location); + void replace(BuildContext context) => + context.replace(location, extra: $extra); } extension $ContainerEditRouteExtension on ContainerEditRoute { static ContainerEditRoute _fromState(GoRouterState state) => - ContainerEditRoute(); + ContainerEditRoute(state.extra as ContainerData); String get location => GoRouteData.$location('/containers/edit'); - void go(BuildContext context) => context.go(location); + void go(BuildContext context) => context.go(location, extra: $extra); - Future push(BuildContext context) => context.push(location); + Future push(BuildContext context) => + context.push(location, extra: $extra); void pushReplacement(BuildContext context) => - context.pushReplacement(location); + context.pushReplacement(location, extra: $extra); - void replace(BuildContext context) => context.replace(location); + void replace(BuildContext context) => + context.replace(location, extra: $extra); } RouteBase get $bangCategoriesRoute => GoRouteData.$route( @@ -492,6 +502,12 @@ RouteBase get $feedListRoute => GoRouteData.$route( factory: $FeedListRouteExtension._fromState, routes: [ + GoRouteData.$route( + path: 'add', + name: 'FeedAddRoute', + + factory: $FeedAddRouteExtension._fromState, + ), GoRouteData.$route( path: 'articles/:feedId', name: 'FeedArticleListRoute', @@ -505,11 +521,23 @@ RouteBase get $feedListRoute => GoRouteData.$route( factory: $FeedArticleRouteExtension._fromState, ), GoRouteData.$route( - path: 'create', + path: 'create/:feedId', name: 'FeedCreateRoute', factory: $FeedCreateRouteExtension._fromState, ), + GoRouteData.$route( + path: 'available/:feedsJson', + name: 'SelectFeedDialogRoute', + + factory: $SelectFeedDialogRouteExtension._fromState, + ), + GoRouteData.$route( + path: 'edit/:feedId', + name: 'FeedEditRoute', + + factory: $FeedEditRouteExtension._fromState, + ), ], ); @@ -528,6 +556,24 @@ extension $FeedListRouteExtension on FeedListRoute { void replace(BuildContext context) => context.replace(location); } +extension $FeedAddRouteExtension on FeedAddRoute { + static FeedAddRoute _fromState(GoRouterState state) => + FeedAddRoute($extra: state.extra as Uri?); + + String get location => GoRouteData.$location('/feeds/add'); + + void go(BuildContext context) => context.go(location, extra: $extra); + + Future push(BuildContext context) => + context.push(location, extra: $extra); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: $extra); + + void replace(BuildContext context) => + context.replace(location, extra: $extra); +} + extension $FeedArticleListRouteExtension on FeedArticleListRoute { static FeedArticleListRoute _fromState(GoRouterState state) => FeedArticleListRoute(feedId: Uri.parse(state.pathParameters['feedId']!)!); @@ -564,9 +610,48 @@ extension $FeedArticleRouteExtension on FeedArticleRoute { } extension $FeedCreateRouteExtension on FeedCreateRoute { - static FeedCreateRoute _fromState(GoRouterState state) => FeedCreateRoute(); + static FeedCreateRoute _fromState(GoRouterState state) => + FeedCreateRoute(feedId: Uri.parse(state.pathParameters['feedId']!)!); - String get location => GoRouteData.$location('/feeds/create'); + String get location => GoRouteData.$location( + '/feeds/create/${Uri.encodeComponent(feedId.toString())}', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + +extension $SelectFeedDialogRouteExtension on SelectFeedDialogRoute { + static SelectFeedDialogRoute _fromState(GoRouterState state) => + SelectFeedDialogRoute(feedsJson: state.pathParameters['feedsJson']!); + + String get location => GoRouteData.$location( + '/feeds/available/${Uri.encodeComponent(feedsJson)}', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + +extension $FeedEditRouteExtension on FeedEditRoute { + static FeedEditRoute _fromState(GoRouterState state) => + FeedEditRoute(feedId: Uri.parse(state.pathParameters['feedId']!)!); + + String get location => GoRouteData.$location( + '/feeds/edit/${Uri.encodeComponent(feedId.toString())}', + ); void go(BuildContext context) => context.go(location); diff --git a/app/lib/data/models/web_page_info.dart b/app/lib/data/models/web_page_info.dart index 1ccfc733..5a503bd0 100644 --- a/app/lib/data/models/web_page_info.dart +++ b/app/lib/data/models/web_page_info.dart @@ -10,7 +10,7 @@ class WebPageInfo with FastEquatable { final Uri url; final String? title; final BrowserIcon? favicon; - final Set? feeds; + final Set? feeds; bool get isPageInfoComplete => title.isNotEmpty && favicon != null && feeds != null; diff --git a/app/lib/data/models/web_page_info.g.dart b/app/lib/data/models/web_page_info.g.dart index 49b27f24..4180fb4e 100644 --- a/app/lib/data/models/web_page_info.g.dart +++ b/app/lib/data/models/web_page_info.g.dart @@ -13,7 +13,7 @@ abstract class _$WebPageInfoCWProxy { WebPageInfo favicon(BrowserIcon? favicon); - WebPageInfo feeds(Set? feeds); + WebPageInfo feeds(Set? feeds); /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebPageInfo(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. /// @@ -25,7 +25,7 @@ abstract class _$WebPageInfoCWProxy { Uri url, String? title, BrowserIcon? favicon, - Set? feeds, + Set? feeds, }); } @@ -45,7 +45,7 @@ class _$WebPageInfoCWProxyImpl implements _$WebPageInfoCWProxy { WebPageInfo favicon(BrowserIcon? favicon) => this(favicon: favicon); @override - WebPageInfo feeds(Set? feeds) => this(feeds: feeds); + WebPageInfo feeds(Set? feeds) => this(feeds: feeds); @override /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebPageInfo(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. @@ -80,7 +80,7 @@ class _$WebPageInfoCWProxyImpl implements _$WebPageInfoCWProxy { feeds == const $CopyWithPlaceholder() ? _value.feeds // ignore: cast_nullable_to_non_nullable - : feeds as Set?, + : feeds as Set?, ); } } diff --git a/app/lib/domain/services/generic_website.dart b/app/lib/domain/services/generic_website.dart index ee4644ea..0bed9a80 100644 --- a/app/lib/domain/services/generic_website.dart +++ b/app/lib/domain/services/generic_website.dart @@ -186,9 +186,11 @@ class GenericWebsiteService extends _$GenericWebsiteService { return icons; } - Future> fetchPageInfo(Uri url) { + Future> fetchPageInfo(Uri url, bool isImageRequest) { return Result.fromAsync(() async { - final result = await compute((String urlString) async { + final result = await compute((args) async { + final [String urlString, bool isImageRequest] = args; + final client = http.Client(); try { final baseUri = Uri.parse(urlString); @@ -196,6 +198,16 @@ class GenericWebsiteService extends _$GenericWebsiteService { .get(baseUri) .timeout(const Duration(seconds: 15)); + //When this is a request for an icon and we hit an image, directly return it + if (isImageRequest) { + final contentType = response.headers['content-type']; + if (contentType?.contains('image/') == true) { + return { + 'imageBytes': [response.bodyBytes], + }; + } + } + final document = html_parser.parse(response.body); final title = document.querySelector('title')?.text; @@ -206,12 +218,23 @@ class GenericWebsiteService extends _$GenericWebsiteService { return { 'title': title, 'resources': resources.map(_serializeResource).toList(), - 'feeds': feeds, + 'feeds': feeds.map((uri) => uri.toString()).toList(), }; } finally { client.close(); } - }, url.toString()); + }, [url.toString(), isImageRequest]); + + if (result['imageBytes'] case final Uint8List imageBytes) { + return WebPageInfo( + url: url, + favicon: await BrowserIcon.fromBytes( + imageBytes, + dominantColor: null, + source: IconSource.download, + ), + ); + } final resources = (result['resources']! as List>) @@ -226,7 +249,9 @@ class GenericWebsiteService extends _$GenericWebsiteService { url: url, title: result['title'] as String?, favicon: favicon, - feeds: result['feeds'] as Set?, + feeds: Set.from( + (result['feeds']! as List).map((url) => Uri.tryParse(url)), + ), ); }, exceptionHandler: handleHttpError); } @@ -277,17 +302,29 @@ class GenericWebsiteService extends _$GenericWebsiteService { ); } - Future getUrlIcon(Uri url) async { - final cachedIcon = await getCachedIcon(url); + Future getUrlIcon(List urlList) async { + for (final url in urlList) { + final cachedIcon = await getCachedIcon(url); - if (cachedIcon != null) { - return cachedIcon; + if (cachedIcon != null) { + return cachedIcon; + } + + final result = await ref.read( + pageInfoProvider(url, isImageRequest: true).future, + ); + + if (result.favicon case final BrowserIcon favicon) { + //If it was a `isImageRequest` hit, we need to cache it at this point + if (!_browserIconCache.contains(url.origin)) { + _browserIconCache.set(url.origin, favicon); + } + + return favicon; + } } - final result = await ref.read(pageInfoProvider(url).future); - final favicon = result.favicon!; - - return favicon; + return null; } // Future tryUpgradeToHttps(Uri httpUri) async { diff --git a/app/lib/domain/services/generic_website.g.dart b/app/lib/domain/services/generic_website.g.dart index 07d2dcf1..8e4f8faa 100644 --- a/app/lib/domain/services/generic_website.g.dart +++ b/app/lib/domain/services/generic_website.g.dart @@ -7,7 +7,7 @@ part of 'generic_website.dart'; // ************************************************************************** String _$genericWebsiteServiceHash() => - r'9d17f41596579c289a6cd021ed8d8049e1fb7aff'; + r'9bba5a6fc01aa788b2674db6ae3869065d6a528d'; /// See also [GenericWebsiteService]. @ProviderFor(GenericWebsiteService) diff --git a/app/lib/features/bangs/data/models/bang.dart b/app/lib/features/bangs/data/models/bang.dart index 0bbffc6c..48bc961c 100644 --- a/app/lib/features/bangs/data/models/bang.dart +++ b/app/lib/features/bangs/data/models/bang.dart @@ -71,7 +71,7 @@ class Bang with FastEquatable implements Insertable { : input; } - Uri getUrl(String? query) { + Uri getTemplateUrl(String? query) { final url = (query != null) ? urlTemplate.replaceAll( diff --git a/app/lib/features/bangs/domain/providers/search.dart b/app/lib/features/bangs/domain/providers/search.dart index 6c2e9c36..60fb9ba5 100644 --- a/app/lib/features/bangs/domain/providers/search.dart +++ b/app/lib/features/bangs/domain/providers/search.dart @@ -22,7 +22,7 @@ class BangSearch extends _$BangSearch { maxEntryCount: 3, ); //TODO: make count dynamic - return bang.getUrl(searchQuery); + return bang.getTemplateUrl(searchQuery); } Future search(String input) async { @@ -50,7 +50,7 @@ class BangSearch extends _$BangSearch { } @Riverpod() -class SeamlessBangProvider extends _$SeamlessBangProvider { +class SeamlessBang extends _$SeamlessBang { bool _hasSearch = false; void search(String input) { @@ -60,6 +60,7 @@ class SeamlessBangProvider extends _$SeamlessBangProvider { ref.invalidateSelf(); } + //Don't block unawaited(ref.read(bangSearchProvider.notifier).search(input)); } else if (_hasSearch) { _hasSearch = false; diff --git a/app/lib/features/bangs/domain/providers/search.g.dart b/app/lib/features/bangs/domain/providers/search.g.dart index 21fadbf3..0f7fe5de 100644 --- a/app/lib/features/bangs/domain/providers/search.g.dart +++ b/app/lib/features/bangs/domain/providers/search.g.dart @@ -6,7 +6,7 @@ part of 'search.dart'; // RiverpodGenerator // ************************************************************************** -String _$bangSearchHash() => r'ff7035d269041c6415f2a2afb0a71ef67d3b9841'; +String _$bangSearchHash() => r'6b7452d48698c01870c5c75f0c10cadaafc481c8'; /// See also [BangSearch]. @ProviderFor(BangSearch) @@ -23,26 +23,22 @@ final bangSearchProvider = ); typedef _$BangSearch = AutoDisposeStreamNotifier>; -String _$seamlessBangProviderHash() => - r'c4c0b452c94ba447be5b02dfef5bc16148cc3dc7'; +String _$seamlessBangHash() => r'8bd7a2cbe4c302ae08f85167290666a7437f8b9b'; -/// See also [SeamlessBangProvider]. -@ProviderFor(SeamlessBangProvider) -final seamlessBangProviderProvider = AutoDisposeNotifierProvider< - SeamlessBangProvider, +/// See also [SeamlessBang]. +@ProviderFor(SeamlessBang) +final seamlessBangProvider = AutoDisposeNotifierProvider< + SeamlessBang, AsyncValue> >.internal( - SeamlessBangProvider.new, - name: r'seamlessBangProviderProvider', + SeamlessBang.new, + name: r'seamlessBangProvider', debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$seamlessBangProviderHash, + const bool.fromEnvironment('dart.vm.product') ? null : _$seamlessBangHash, dependencies: null, allTransitiveDependencies: null, ); -typedef _$SeamlessBangProvider = - AutoDisposeNotifier>>; +typedef _$SeamlessBang = AutoDisposeNotifier>>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/bangs/presentation/screens/categories.dart b/app/lib/features/bangs/presentation/screens/categories.dart index 0d162200..d191b6d3 100644 --- a/app/lib/features/bangs/presentation/screens/categories.dart +++ b/app/lib/features/bangs/presentation/screens/categories.dart @@ -1,7 +1,6 @@ import 'package:fading_scroll/fading_scroll.dart'; 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/providers/bangs.dart'; @@ -19,13 +18,14 @@ class BangCategoriesScreen extends HookConsumerWidget { actions: [ IconButton( onPressed: () async { - await context.push(const BangSearchRoute().location); + await const BangSearchRoute().push(context); }, icon: const Icon(Icons.search), ), ], ), body: categoriesAsync.when( + skipLoadingOnReload: true, data: (categories) { return FadingScroll( fadingSize: 25, @@ -66,13 +66,10 @@ class BangCategoriesScreen extends HookConsumerWidget { (subCategory) => ListTile( title: Text(subCategory), onTap: () async { - await context.push( - BangSubCategoryRoute( - category: category.key, - subCategory: - subCategory, - ).location, - ); + await BangSubCategoryRoute( + category: category.key, + subCategory: subCategory, + ).push(context); }, ), ) diff --git a/app/lib/features/bangs/presentation/screens/list.dart b/app/lib/features/bangs/presentation/screens/list.dart index 7f32da2d..4023faba 100644 --- a/app/lib/features/bangs/presentation/screens/list.dart +++ b/app/lib/features/bangs/presentation/screens/list.dart @@ -1,5 +1,4 @@ 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/extensions/nullable.dart'; @@ -29,6 +28,7 @@ class BangListScreen extends HookConsumerWidget { slivers: [ SliverAppBar.medium(title: Text('$category: $subCategory')), bangsAsync.when( + skipLoadingOnReload: true, data: (bangs) { return SliverList.builder( itemCount: bangs.length, @@ -41,7 +41,7 @@ class BangListScreen extends HookConsumerWidget { .read(selectedBangTriggerProvider().notifier) .setTrigger(bang.trigger); - context.go(const SearchRoute().location); + const SearchRoute().go(context); }, ); }, diff --git a/app/lib/features/bangs/presentation/widgets/bang_details.dart b/app/lib/features/bangs/presentation/widgets/bang_details.dart index 451b557e..89736bbd 100644 --- a/app/lib/features/bangs/presentation/widgets/bang_details.dart +++ b/app/lib/features/bangs/presentation/widgets/bang_details.dart @@ -1,5 +1,4 @@ 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'; @@ -38,7 +37,7 @@ class BangDetails extends HookConsumerWidget { children: [ Row( children: [ - UrlIcon(bangData.getUrl(''), iconSize: 34.0), + UrlIcon([bangData.getTemplateUrl('')], iconSize: 34.0), const SizedBox(width: 12.0), Expanded( child: Column( @@ -67,14 +66,14 @@ class BangDetails extends HookConsumerWidget { visualDensity: VisualDensity.compact, ), onPressed: () async { - final url = Uri.parse(bangData.getUrl('').origin); + final url = Uri.parse(bangData.getTemplateUrl('').origin); await ref .read(tabRepositoryProvider.notifier) .addTab(url: url); if (context.mounted) { - context.go(BrowserRoute().location); + BrowserRoute().go(context); } }, label: Text(bangData.domain), diff --git a/app/lib/features/bangs/presentation/widgets/site_search.dart b/app/lib/features/bangs/presentation/widgets/site_search.dart index 782cc705..92ff1fd1 100644 --- a/app/lib/features/bangs/presentation/widgets/site_search.dart +++ b/app/lib/features/bangs/presentation/widgets/site_search.dart @@ -73,7 +73,8 @@ class SiteSearch extends HookConsumerWidget { width: double.maxFinite, child: SelectableChips( itemId: (bang) => bang.trigger, - itemAvatar: (bang) => UrlIcon(bang.getUrl(''), iconSize: 20), + itemAvatar: + (bang) => UrlIcon([bang.getTemplateUrl('')], iconSize: 20), itemLabel: (bang) => Text(bang.websiteName), availableItems: availableBangs, selectedItem: selectedBang, diff --git a/app/lib/features/geckoview/domain/providers.dart b/app/lib/features/geckoview/domain/providers.dart index 785704ce..0c0efdc4 100644 --- a/app/lib/features/geckoview/domain/providers.dart +++ b/app/lib/features/geckoview/domain/providers.dart @@ -29,7 +29,7 @@ GeckoSelectionActionService selectionActionService(Ref ref) { await ref .read(tabRepositoryProvider.notifier) .addTab( - url: defaultSearchBang.getUrl(text), + url: defaultSearchBang.getTemplateUrl(text), parentId: currentTabId, ); } else { diff --git a/app/lib/features/geckoview/domain/providers.g.dart b/app/lib/features/geckoview/domain/providers.g.dart index 73610be4..12db7e86 100644 --- a/app/lib/features/geckoview/domain/providers.g.dart +++ b/app/lib/features/geckoview/domain/providers.g.dart @@ -7,7 +7,7 @@ part of 'providers.dart'; // ************************************************************************** String _$selectionActionServiceHash() => - r'8d0af53213bdb924bcf5f8df6ceec6c81a345345'; + r'd907a3c5ab7efde82bb2cc1fb0409fd3fd13bb25'; /// See also [selectionActionService]. @ProviderFor(selectionActionService) diff --git a/app/lib/features/geckoview/domain/providers/browser_extension.dart b/app/lib/features/geckoview/domain/providers/browser_extension.dart new file mode 100644 index 00000000..962aee70 --- /dev/null +++ b/app/lib/features/geckoview/domain/providers/browser_extension.dart @@ -0,0 +1,22 @@ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'browser_extension.g.dart'; + +@Riverpod(keepAlive: true) +GeckoBrowserExtensionService browserExtensionService(Ref ref) { + final service = GeckoBrowserExtensionService.setUp(); + + ref.onDispose(() { + service.dispose(); + }); + + return service; +} + +@Riverpod() +Stream feedRequested(Ref ref) { + final service = ref.watch(browserExtensionServiceProvider); + return service.feedRequested; +} diff --git a/app/lib/features/geckoview/domain/providers/browser_extension.g.dart b/app/lib/features/geckoview/domain/providers/browser_extension.g.dart new file mode 100644 index 00000000..1d1f1f67 --- /dev/null +++ b/app/lib/features/geckoview/domain/providers/browser_extension.g.dart @@ -0,0 +1,48 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'browser_extension.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$browserExtensionServiceHash() => + r'c3f67763e0abb10039b407fced6e175e5ec7c6c3'; + +/// See also [browserExtensionService]. +@ProviderFor(browserExtensionService) +final browserExtensionServiceProvider = + Provider.internal( + browserExtensionService, + name: r'browserExtensionServiceProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$browserExtensionServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef BrowserExtensionServiceRef = ProviderRef; +String _$feedRequestedHash() => r'4f179d0878072a77a87422ff6afdac53a3d57c04'; + +/// See also [feedRequested]. +@ProviderFor(feedRequested) +final feedRequestedProvider = AutoDisposeStreamProvider.internal( + feedRequested, + name: r'feedRequestedProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$feedRequestedHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef FeedRequestedRef = AutoDisposeStreamProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/domain/repositories/tab.dart b/app/lib/features/geckoview/domain/repositories/tab.dart index 5886a18f..b61a0fe6 100644 --- a/app/lib/features/geckoview/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/domain/repositories/tab.dart @@ -162,7 +162,7 @@ class TabRepository extends _$TabRepository { ref.read(selectedBangDataProvider()) ?? await ref.read(defaultSearchBangDataProvider.future); - await addTab(url: defaultSearchBang?.getUrl(value.text)); + await addTab(url: defaultSearchBang?.getTemplateUrl(value.text)); } }); }); diff --git a/app/lib/features/geckoview/domain/repositories/tab.g.dart b/app/lib/features/geckoview/domain/repositories/tab.g.dart index c476860c..6240102e 100644 --- a/app/lib/features/geckoview/domain/repositories/tab.g.dart +++ b/app/lib/features/geckoview/domain/repositories/tab.g.dart @@ -6,7 +6,7 @@ part of 'tab.dart'; // RiverpodGenerator // ************************************************************************** -String _$tabRepositoryHash() => r'12e4fb7e9bf36a6df9f0a29df4b410f051d5c38e'; +String _$tabRepositoryHash() => r'69e283b820d86388f4d80e71e08761e2b6890507'; /// See also [TabRepository]. @ProviderFor(TabRepository) diff --git a/app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart b/app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart index bd1f2331..3787f6d0 100644 --- a/app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart +++ b/app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart @@ -81,6 +81,7 @@ class WebPageDialog extends HookConsumerWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: availableBangsAsync.when( + skipLoadingOnReload: true, data: (availableBangs) { if (availableBangs.isEmpty) { return const SizedBox.shrink(); diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart index d52740d2..a102ad3f 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -53,10 +53,7 @@ class BrowserScreen extends HookConsumerWidget { useOnStreamChange( eventService.longPressEvent, onData: (event) async { - await context.push( - const ContextMenuRoute().location, - extra: event.hitResult.toJson(), - ); + await ContextMenuRoute(event.hitResult.toJson()).push(context); }, ); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/address_with_suggestions_field.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/address_with_suggestions_field.dart index 2f5e6764..28a627f7 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/address_with_suggestions_field.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/address_with_suggestions_field.dart @@ -66,7 +66,7 @@ class AddressWithSuggestionsField extends HookConsumerWidget { defaultSearchBangDataProvider.future, ); - newUrl = defaultSearchBang?.getUrl(value); + newUrl = defaultSearchBang?.getTemplateUrl(value); } if (newUrl != null) { diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index 14a93d08..30b06a3b 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -17,7 +17,6 @@ import 'package:lensai/features/geckoview/features/browser/presentation/widgets/ import 'package:lensai/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart'; import 'package:lensai/features/geckoview/features/find_in_page/presentation/controllers/find_in_page_visibility.dart'; import 'package:lensai/features/geckoview/features/readerview/presentation/widgets/reader_button.dart'; -import 'package:lensai/features/web_feed/domain/services/feed_reader.dart'; import 'package:lensai/presentation/hooks/menu_controller.dart'; import 'package:lensai/presentation/icons/tor_icons.dart'; import 'package:lensai/utils/ui_helper.dart' as ui_helper; @@ -55,12 +54,10 @@ class BrowserBottomAppBar extends HookConsumerWidget { ? AppBarTitle( tab: tabState, onTap: () async { - await context.push( - WebPageRoute( - url: tabState.url.toString(), - ).location, - extra: tabState, - ); + await WebPageRoute( + url: tabState.url.toString(), + $extra: tabState, + ).push(context); }, onLongPress: () async { final newUrl = await showDialog( @@ -104,7 +101,7 @@ class BrowserBottomAppBar extends HookConsumerWidget { ), MenuItemButton( onPressed: () async { - await context.push(const SearchRoute().location); + await const SearchRoute().push(context); }, leadingIcon: const Icon(Icons.add), child: const Text('Add Tab'), @@ -183,7 +180,7 @@ class BrowserBottomAppBar extends HookConsumerWidget { ), MenuItemButton( onPressed: () async { - await context.push(AboutRoute().location); + await AboutRoute().push(context); }, leadingIcon: const Icon(Icons.info), child: const Text('About'), @@ -191,7 +188,7 @@ class BrowserBottomAppBar extends HookConsumerWidget { const Divider(), MenuItemButton( onPressed: () async { - await context.push(SettingsRoute().location); + await SettingsRoute().push(context); }, leadingIcon: const Icon(Icons.settings), child: const Text('Settings'), @@ -234,7 +231,7 @@ class BrowserBottomAppBar extends HookConsumerWidget { ), MenuItemButton( onPressed: () async { - await context.push(TorProxyRoute().location); + await TorProxyRoute().push(context); }, leadingIcon: const Icon(TorIcons.onionAlt), child: const Text('Tor'), @@ -242,11 +239,18 @@ class BrowserBottomAppBar extends HookConsumerWidget { const Divider(), MenuItemButton( onPressed: () async { - await context.push(BangCategoriesRoute().location); + await BangCategoriesRoute().push(context); }, leadingIcon: const Icon(MdiIcons.exclamationThick), child: const Text('Bangs'), ), + MenuItemButton( + onPressed: () async { + await context.push(FeedListRoute().location); + }, + leadingIcon: const Icon(Icons.rss_feed), + child: const Text('Feeds'), + ), const Divider(), if (selectedTabId != null) MenuItemButton( @@ -358,31 +362,6 @@ class BrowserBottomAppBar extends HookConsumerWidget { ); }, ), - MenuItemButton( - onPressed: () async { - final res = await ref - .read(feedReaderProvider.notifier) - .parseFeed( - Uri.parse('https://simonwillison.net/atom/everything/'), - ); - - if (context.mounted) { - await context.push( - FeedCreateRoute().location, - extra: res.feedData, - ); - } - }, - leadingIcon: const Icon(Icons.info), - child: const Text('Add'), - ), - MenuItemButton( - onPressed: () async { - await context.push(FeedListRoute().location); - }, - leadingIcon: const Icon(Icons.info), - child: const Text('List'), - ), ], ), ], diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index 630fd269..261a3b69 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -4,7 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/core/logger.dart'; +import 'package:lensai/core/routing/routes.dart'; +import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/features/geckoview/domain/providers.dart'; +import 'package:lensai/features/geckoview/domain/providers/browser_extension.dart'; import 'package:lensai/features/geckoview/domain/providers/selected_tab.dart'; import 'package:lensai/features/geckoview/domain/providers/tab_session.dart'; import 'package:lensai/features/geckoview/domain/providers/tab_state.dart'; @@ -78,6 +81,12 @@ class _BrowserViewState extends ConsumerState }, ); + ref.listen(feedRequestedProvider, (previous, next) async { + if (next.valueOrNull.mapNotNull(Uri.tryParse) case final Uri url) { + await FeedAddRoute($extra: url).push(context); + } + }); + return Visibility( visible: hasTab, replacement: SizedBox.expand(child: Container(color: Colors.grey[800])), diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/edit_url_dialog.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/edit_url_dialog.dart index 96f0a04d..7e16585e 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/edit_url_dialog.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/edit_url_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lensai/utils/form_validators.dart'; import 'package:lensai/utils/uri_parser.dart' as uri_parser; class EditUrlDialog extends HookWidget { @@ -22,13 +23,7 @@ class EditUrlDialog extends HookWidget { keyboardType: TextInputType.url, decoration: const InputDecoration(hintText: 'Enter URL'), validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a URL'; - } - if (uri_parser.tryParseUrl(value) == null) { - return 'Please enter a valid URL'; - } - return null; + return validateUrl(value, requireAuthority: false); }, ), ), @@ -40,7 +35,9 @@ class EditUrlDialog extends HookWidget { TextButton( onPressed: () { if (formKey.currentState?.validate() ?? false) { - Navigator.of(context).pop(Uri.parse(urlController.text)); + Navigator.of(context).pop( + uri_parser.tryParseUrl(urlController.text, eagerParsing: true), + ); } }, child: const Text('Edit'), diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart index d077c696..6d000efc 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart @@ -4,7 +4,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_reorderable_grid_view/widgets/widgets.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/core/providers/global_drop.dart'; import 'package:lensai/core/routing/routes.dart'; @@ -376,7 +375,7 @@ class ViewTabsSheetWidget extends HookConsumerWidget { ), child: FloatingActionButton.small( onPressed: () async { - await context.push(const SearchRoute().location); + await const SearchRoute().push(context); onClose(); }, diff --git a/app/lib/features/geckoview/features/readerview/presentation/widgets/reader_button.dart b/app/lib/features/geckoview/features/readerview/presentation/widgets/reader_button.dart index b4c18bd0..45419cef 100644 --- a/app/lib/features/geckoview/features/readerview/presentation/widgets/reader_button.dart +++ b/app/lib/features/geckoview/features/readerview/presentation/widgets/reader_button.dart @@ -45,6 +45,7 @@ class ReaderButton extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.symmetric(vertical: 15.0, horizontal: 8.0), child: readerChanging.when( + skipLoadingOnReload: true, data: (_) => Visibility( visible: readerabilityState.readerable, diff --git a/app/lib/features/geckoview/features/search/presentation/screens/search.dart b/app/lib/features/geckoview/features/search/presentation/screens/search.dart index 1d57ae8c..4fd56cad 100644 --- a/app/lib/features/geckoview/features/search/presentation/screens/search.dart +++ b/app/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -86,7 +86,7 @@ class SearchScreen extends HookConsumerWidget { ref.read(selectedBangDataProvider()) ?? await ref.read(defaultSearchBangDataProvider.future); - newUrl = defaultSearchBang?.getUrl(value); + newUrl = defaultSearchBang?.getTemplateUrl(value); } if (newUrl != null) { diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/bang_chips.dart b/app/lib/features/geckoview/features/search/presentation/widgets/bang_chips.dart index 57de17c2..43e6d24f 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/bang_chips.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/bang_chips.dart @@ -1,5 +1,4 @@ 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/extensions/nullable.dart'; @@ -52,15 +51,16 @@ class BangChips extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final availableBangs = ref.watch(seamlessBangProviderProvider); + final availableBangs = ref.watch(seamlessBangProvider); useListenableCallback(searchTextController, () { ref - .read(seamlessBangProviderProvider.notifier) + .read(seamlessBangProvider.notifier) .search(searchTextController!.text); }); return availableBangs.when( + skipLoadingOnReload: true, data: (availableBangs) { return SizedBox( height: 48, @@ -71,7 +71,8 @@ class BangChips extends HookConsumerWidget { child: SelectableChips( itemId: (bang) => bang.trigger, itemAvatar: - (bang) => UrlIcon(bang.getUrl(''), iconSize: 20), + (bang) => + UrlIcon([bang.getTemplateUrl('')], iconSize: 20), itemLabel: (bang) => Text(bang.websiteName), availableItems: availableBangs, selectedItem: activeBang, @@ -97,14 +98,12 @@ class BangChips extends HookConsumerWidget { onPressed: () async { final searchText = searchTextController?.text.trim(); - await context.push( - BangSearchRoute( - searchText: - (searchText.isEmpty) - ? BangSearchRoute.emptySearchText - : searchText!, - ).location, - ); + await BangSearchRoute( + searchText: + (searchText.isEmpty) + ? BangSearchRoute.emptySearchText + : searchText!, + ).push(context); }, icon: const Icon(Icons.chevron_right), ), diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_field.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_field.dart index 1638b270..2dea0c0b 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_field.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_field.dart @@ -67,7 +67,9 @@ class SearchField extends HookConsumerWidget { (showBangIcon && activeBang != null) ? Padding( padding: const EdgeInsetsDirectional.all(12.0), - child: UrlIcon(activeBang!.getUrl(''), iconSize: 24.0), + child: UrlIcon([ + activeBang!.getTemplateUrl(''), + ], iconSize: 24.0), ) : null, label: label ?? const Text('Search'), diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart index 4c9f7be2..aa165bf7 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart @@ -48,6 +48,7 @@ class HistorySuggestions extends HookConsumerWidget { SliverSkeletonizer( enabled: historySuggestions.isLoading, child: historySuggestions.when( + skipLoadingOnReload: true, data: (historySuggestions) { return SliverList.builder( itemCount: historySuggestions.length, diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart index a0c59ca0..ba5d47c0 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart @@ -96,13 +96,11 @@ class TabSearch extends HookConsumerWidget { return ListTile( leading: RepaintBoundary( child: - (result.icon != null) - ? RawImage( - image: result.icon?.value, - height: 24, - width: 24, - ) - : UrlIcon(result.url, iconSize: 24), + result.icon.mapNotNull( + (icon) => + RawImage(image: icon.value, height: 24, width: 24), + ) ?? + UrlIcon([result.url], iconSize: 24), ), title: result.title.mapNotNull( (title) => MarkdownBody( diff --git a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart index cbfc7d15..aeeb845b 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart @@ -226,11 +226,7 @@ class TabDao extends DatabaseAccessor with _$TabDaoMixin { ellipsis: ellipsis, ); } else { - return db.queryTabsBasic( - query: db.buildLikeQuery(searchString), - beforeMatch: matchPrefix, - afterMatch: matchSuffix, - ); + return db.queryTabsBasic(query: db.buildLikeQuery(searchString)); } } } diff --git a/app/lib/features/geckoview/features/tabs/data/database/database.drift b/app/lib/features/geckoview/features/tabs/data/database/database.drift index a0bf1d70..c356b383 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/database.drift +++ b/app/lib/features/geckoview/features/tabs/data/database/database.drift @@ -126,10 +126,10 @@ queryTabsBasic WITH TabQueryResult: ) 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, - t.url AS clean_url + t.title, + CAST(t.url AS TEXT) AS url, + t.url AS clean_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 @@ -156,11 +156,11 @@ queryTabsFullContent WITH TabQueryResult: 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, + t.url AS clean_url, ( bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight) - ) AS weighted_rank, - t.url AS clean_url + ) AS weighted_rank FROM tab_fts(:query) fts INNER JOIN tab t ON t.rowid = fts.rowid diff --git a/app/lib/features/geckoview/features/tabs/data/database/database.g.dart b/app/lib/features/geckoview/features/tabs/data/database/database.g.dart index d66ea586..4efcaf48 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/database.g.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/database.g.dart @@ -1901,18 +1901,10 @@ abstract class _$TabDatabase extends GeneratedDatabase { ).map((QueryRow row) => row.read('_c0')); } - Selectable queryTabsBasic({ - required String beforeMatch, - required String afterMatch, - required String query, - }) { + Selectable queryTabsBasic({required String query}) { return customSelect( - 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight) SELECT t.id, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank, t.url AS clean_url FROM tab_fts AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights WHERE fts.title LIKE ?3 OR fts.url LIKE ?3 ORDER BY weighted_rank ASC, t.timestamp DESC', - variables: [ - Variable(beforeMatch), - Variable(afterMatch), - Variable(query), - ], + 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight) SELECT t.id, t.title, CAST(t.url AS TEXT) AS url, t.url AS clean_url, bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank FROM tab_fts AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights WHERE fts.title LIKE ?1 OR fts.url LIKE ?1 ORDER BY weighted_rank ASC, t.timestamp DESC', + variables: [Variable(query)], readsFrom: {tab, tabFts}, ).map( (QueryRow row) => TabQueryResult( @@ -1936,7 +1928,7 @@ abstract class _$TabDatabase extends GeneratedDatabase { required String query, }) { return customSelect( - 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT t.id, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, snippet(tab_fts, 2, ?1, ?2, ?3, ?4) AS extracted_content, snippet(tab_fts, 3, ?1, ?2, ?3, ?4) AS full_content,(bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight))AS weighted_rank, t.url AS clean_url FROM tab_fts(?5)AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights ORDER BY weighted_rank ASC, t.timestamp DESC', + 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT t.id, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, snippet(tab_fts, 2, ?1, ?2, ?3, ?4) AS extracted_content, snippet(tab_fts, 3, ?1, ?2, ?3, ?4) AS full_content, t.url AS clean_url,(bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight))AS weighted_rank FROM tab_fts(?5)AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights ORDER BY weighted_rank ASC, t.timestamp DESC', variables: [ Variable(beforeMatch), Variable(afterMatch), diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart index e94bf5cd..f5ed48c4 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart @@ -1,7 +1,6 @@ import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.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/core/uuid.dart'; @@ -28,6 +27,7 @@ class ContainerListScreen extends HookConsumerWidget { return Skeletonizer( enabled: containersAsync.isLoading, child: containersAsync.when( + skipLoadingOnReload: true, data: (containers) => FadingScroll( fadingSize: 25, @@ -115,10 +115,9 @@ class ContainerListScreen extends HookConsumerWidget { .unusedRandomContainerColor(); if (context.mounted) { - final result = await context.push( - ContainerCreateRoute().location, - extra: ContainerData(id: uuid.v7(), color: initialColor), - ); + final result = await ContainerCreateRoute( + ContainerData(id: uuid.v7(), color: initialColor), + ).push(context); if (result != null) { await ref diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart index 55988d19..b0820ca7 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart @@ -1,6 +1,5 @@ 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/providers/global_drop.dart'; import 'package:lensai/core/routing/routes.dart'; @@ -201,7 +200,7 @@ class ContainerChips extends HookConsumerWidget { if (displayMenu) IconButton( onPressed: () async { - await context.push(ContainerListRoute().location); + await ContainerListRoute().push(context); }, icon: const Icon(Icons.chevron_right), ), diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart index 1aefcae3..c4052d6b 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/features/geckoview/features/tabs/data/models/container_data.dart'; @@ -28,7 +27,7 @@ class ContainerListTile extends HookWidget { title: Text(container.name ?? 'New Container'), trailing: IconButton( onPressed: () async { - await context.push(ContainerEditRoute().location, extra: container); + await ContainerEditRoute(container).push(context); }, icon: const Icon(Icons.chevron_right), ), diff --git a/app/lib/features/settings/presentation/screens/settings.dart b/app/lib/features/settings/presentation/screens/settings.dart index 1c5e4137..40ef6e08 100644 --- a/app/lib/features/settings/presentation/screens/settings.dart +++ b/app/lib/features/settings/presentation/screens/settings.dart @@ -1,7 +1,6 @@ import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; 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'; @@ -31,7 +30,7 @@ class SettingsScreen extends HookConsumerWidget { leading: const Icon(Icons.settings), trailing: const Icon(Icons.chevron_right), onTap: () async { - await context.push(GeneralSettingsRoute().location); + await GeneralSettingsRoute().push(context); }, ), ), @@ -48,7 +47,7 @@ class SettingsScreen extends HookConsumerWidget { leading: const Icon(MdiIcons.web), trailing: const Icon(Icons.chevron_right), onTap: () async { - await context.push(WebEngineSettingsRoute().location); + await WebEngineSettingsRoute().push(context); }, ), ), @@ -65,7 +64,7 @@ class SettingsScreen extends HookConsumerWidget { leading: const Icon(MdiIcons.exclamationThick), trailing: const Icon(Icons.chevron_right), onTap: () async { - await context.push(BangSettingsRoute().location); + await BangSettingsRoute().push(context); }, ), ), diff --git a/app/lib/features/settings/presentation/screens/web_engine_hardening.dart b/app/lib/features/settings/presentation/screens/web_engine_hardening.dart index c0e2ba1e..73faaa12 100644 --- a/app/lib/features/settings/presentation/screens/web_engine_hardening.dart +++ b/app/lib/features/settings/presentation/screens/web_engine_hardening.dart @@ -1,7 +1,6 @@ import 'package:fast_equatable/fast_equatable.dart'; 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/extensions/nullable.dart'; @@ -33,6 +32,7 @@ class WebEngineHardeningScreen extends HookConsumerWidget { return Scaffold( appBar: AppBar(title: const Text('Web Engine Hardening')), body: preferenceGroups.when( + skipLoadingOnReload: true, data: (data) { return Column( children: [ @@ -85,11 +85,9 @@ class WebEngineHardeningScreen extends HookConsumerWidget { ), trailing: const Icon(Icons.chevron_right), onTap: () async { - await context.push( - WebEngineHardeningGroupRoute( - group: group.key, - ).location, - ); + await WebEngineHardeningGroupRoute( + group: group.key, + ).push(context); }, ), ), diff --git a/app/lib/features/settings/presentation/screens/web_engine_settings.dart b/app/lib/features/settings/presentation/screens/web_engine_settings.dart index 8d37ab35..4ae0293e 100644 --- a/app/lib/features/settings/presentation/screens/web_engine_settings.dart +++ b/app/lib/features/settings/presentation/screens/web_engine_settings.dart @@ -2,7 +2,6 @@ import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.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/extensions/nullable.dart'; @@ -307,7 +306,7 @@ class WebEngineSettingsScreen extends HookConsumerWidget { leading: const Icon(MdiIcons.shieldLock), trailing: const Icon(Icons.chevron_right), onTap: () async { - await context.push(WebEngineHardeningRoute().location); + await WebEngineHardeningRoute().push(context); }, ), ], diff --git a/app/lib/features/web_feed/data/database/daos/article.dart b/app/lib/features/web_feed/data/database/daos/article.dart index 4b8761d0..143d16e4 100644 --- a/app/lib/features/web_feed/data/database/daos/article.dart +++ b/app/lib/features/web_feed/data/database/daos/article.dart @@ -10,7 +10,7 @@ class ArticleDao extends DatabaseAccessor with _$ArticleDaoMixin { ArticleDao(super.attachedDatabase); Selectable getFeedArticles(Uri? url) { - final select = db.article.select(); + final select = db.articleView.select(); if (url != null) { select.where((article) => article.feedId.equalsValue(url)); @@ -25,7 +25,7 @@ class ArticleDao extends DatabaseAccessor with _$ArticleDaoMixin { } SingleOrNullSelectable getArticleById(String articleId) { - return db.article.select()..where((row) => row.id.equals(articleId)); + return db.articleView.select()..where((row) => row.id.equals(articleId)); } Future upsertArticles(List articles) { @@ -111,8 +111,6 @@ class ArticleDao extends DatabaseAccessor with _$ArticleDaoMixin { return db.queryArticlesBasic( feedId: feedId?.toString(), query: db.buildLikeQuery(searchString), - beforeMatch: matchPrefix, - afterMatch: matchSuffix, ); } } diff --git a/app/lib/features/web_feed/data/database/daos/feed.dart b/app/lib/features/web_feed/data/database/daos/feed.dart index 8fdafd67..15e496ad 100644 --- a/app/lib/features/web_feed/data/database/daos/feed.dart +++ b/app/lib/features/web_feed/data/database/daos/feed.dart @@ -11,15 +11,19 @@ class FeedDao extends DatabaseAccessor with _$FeedDaoMixin { return db.feed.select(); } - Future updateFeedFetched(Uri url, DateTime fetched) { + SingleOrNullSelectable getFeed(Uri feedId) { + return db.feed.select()..where((feed) => feed.url.equalsValue(feedId)); + } + + Future updateFeedFetched(Uri feedId, DateTime fetched) { final statement = - db.feed.update()..where((feed) => feed.url.equalsValue(url)); + db.feed.update()..where((feed) => feed.url.equalsValue(feedId)); return statement.write(FeedCompanion(lastFetched: Value(fetched))); } - Future deleteFeed(Uri url) { - return db.feed.deleteWhere((feed) => feed.url.equals(url.toString())); + Future deleteFeed(Uri feedId) { + return db.feed.deleteWhere((feed) => feed.url.equals(feedId.toString())); } Future upsertFeed(FeedData feedData) { diff --git a/app/lib/features/web_feed/data/database/database.drift b/app/lib/features/web_feed/data/database/database.drift index e0cf1aa0..f87edb49 100644 --- a/app/lib/features/web_feed/data/database/database.drift +++ b/app/lib/features/web_feed/data/database/database.drift @@ -9,6 +9,8 @@ CREATE TABLE feed ( url TEXT PRIMARY KEY NOT NULL MAPPED BY `const UriConverter()`, title TEXT, description TEXT, + icon TEXT MAPPED BY `const UriConverter()`, + site_link TEXT MAPPED BY `const UriConverter()`, authors TEXT MAPPED BY `const FeedAuthorsConverter()`, tags TEXT MAPPED BY `const FeedCategoriesConverter()`, last_fetched DATETIME @@ -31,6 +33,15 @@ CREATE TABLE article ( contentPlain TEXT ) WITH FeedArticle; +CREATE VIEW article_view WITH FeedArticle AS + SELECT + a.*, + f.icon + FROM + article a + INNER JOIN + feed f on f.url = a.feed_id; + CREATE INDEX article_feed_id ON article (feed_id); CREATE VIRTUAL TABLE article_fts @@ -73,17 +84,19 @@ queryArticlesBasic(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult: ) SELECT a.*, - highlight(article_fts, 0, :beforeMatch, :afterMatch) AS title, + f.icon, ( bm25(article_fts, weights.title_weight) ) AS weighted_rank - FROM article_fts(:query) fts + FROM article_fts fts INNER JOIN article a ON a.rowid = fts.rowid + INNER JOIN + feed f ON f.url = a.feed_id CROSS JOIN weights WHERE fts.title LIKE :query AND - :feed_id IS NULL OR a.feed_id = :feed_id + (:feed_id IS NULL OR a.feed_id = :feed_id) ORDER BY weighted_rank ASC, a.created DESC NULLS LAST; @@ -92,15 +105,16 @@ queryArticlesFullContent(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult: WITH weights AS ( SELECT -- Customize these weights (higher = more important) - 5.0 as title_weight, -- Title matches are most important - 2.0 as summary_weight, -- Summary matches are quite important + 10.0 as title_weight, -- Title matches are most important + 3.0 as summary_weight, -- Summary matches are quite important 1.0 as content_weight -- Content matches are basic ) SELECT a.*, - highlight(article_fts, 0, :beforeMatch, :afterMatch) AS title, - snippet(article_fts, 1, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS summary, - snippet(article_fts, 2, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS content, + f.icon, + highlight(article_fts, 0, :beforeMatch, :afterMatch) AS title_highlight, + snippet(article_fts, 1, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS summary_snippet, + snippet(article_fts, 2, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS content_snippet, ( bm25(article_fts, weights.title_weight, weights.summary_weight, weights.content_weight) @@ -108,6 +122,8 @@ queryArticlesFullContent(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult: FROM article_fts(:query) fts INNER JOIN article a ON a.rowid = fts.rowid + INNER JOIN + feed f ON f.url = a.feed_id CROSS JOIN weights WHERE :feed_id IS NULL OR a.feed_id = :feed_id diff --git a/app/lib/features/web_feed/data/database/database.g.dart b/app/lib/features/web_feed/data/database/database.g.dart index 1baaaf83..9fa10ad7 100644 --- a/app/lib/features/web_feed/data/database/database.g.dart +++ b/app/lib/features/web_feed/data/database/database.g.dart @@ -33,6 +33,24 @@ class Feed extends Table with TableInfo { requiredDuringInsert: false, $customConstraints: '', ); + late final GeneratedColumnWithTypeConverter icon = + GeneratedColumn( + 'icon', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter(Feed.$convertericonn); + late final GeneratedColumnWithTypeConverter siteLink = + GeneratedColumn( + 'site_link', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter(Feed.$convertersiteLinkn); late final GeneratedColumnWithTypeConverter?, String> authors = GeneratedColumn( 'authors', @@ -64,6 +82,8 @@ class Feed extends Table with TableInfo { url, title, description, + icon, + siteLink, authors, tags, lastFetched, @@ -93,6 +113,18 @@ class Feed extends Table with TableInfo { DriftSqlType.string, data['${effectivePrefix}description'], ), + icon: Feed.$convertericonn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}icon'], + ), + ), + siteLink: Feed.$convertersiteLinkn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}site_link'], + ), + ), authors: Feed.$converterauthorsn.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, @@ -118,6 +150,12 @@ class Feed extends Table with TableInfo { } static TypeConverter $converterurl = const UriConverter(); + static TypeConverter $convertericon = const UriConverter(); + static TypeConverter $convertericonn = + NullAwareTypeConverter.wrap($convertericon); + static TypeConverter $convertersiteLink = const UriConverter(); + static TypeConverter $convertersiteLinkn = + NullAwareTypeConverter.wrap($convertersiteLink); static TypeConverter, String> $converterauthors = const FeedAuthorsConverter(); static TypeConverter?, String?> $converterauthorsn = @@ -134,6 +172,8 @@ class FeedData extends DataClass implements Insertable { final Uri url; final String? title; final String? description; + final Uri? icon; + final Uri? siteLink; final List? authors; final List? tags; final DateTime? lastFetched; @@ -141,6 +181,8 @@ class FeedData extends DataClass implements Insertable { required this.url, this.title, this.description, + this.icon, + this.siteLink, this.authors, this.tags, this.lastFetched, @@ -157,6 +199,14 @@ class FeedData extends DataClass implements Insertable { if (!nullToAbsent || description != null) { map['description'] = Variable(description); } + if (!nullToAbsent || icon != null) { + map['icon'] = Variable(Feed.$convertericonn.toSql(icon)); + } + if (!nullToAbsent || siteLink != null) { + map['site_link'] = Variable( + Feed.$convertersiteLinkn.toSql(siteLink), + ); + } if (!nullToAbsent || authors != null) { map['authors'] = Variable(Feed.$converterauthorsn.toSql(authors)); } @@ -178,6 +228,8 @@ class FeedData extends DataClass implements Insertable { url: serializer.fromJson(json['url']), title: serializer.fromJson(json['title']), description: serializer.fromJson(json['description']), + icon: serializer.fromJson(json['icon']), + siteLink: serializer.fromJson(json['site_link']), authors: serializer.fromJson?>(json['authors']), tags: serializer.fromJson?>(json['tags']), lastFetched: serializer.fromJson(json['last_fetched']), @@ -190,6 +242,8 @@ class FeedData extends DataClass implements Insertable { 'url': serializer.toJson(url), 'title': serializer.toJson(title), 'description': serializer.toJson(description), + 'icon': serializer.toJson(icon), + 'site_link': serializer.toJson(siteLink), 'authors': serializer.toJson?>(authors), 'tags': serializer.toJson?>(tags), 'last_fetched': serializer.toJson(lastFetched), @@ -200,6 +254,8 @@ class FeedData extends DataClass implements Insertable { Uri? url, Value title = const Value.absent(), Value description = const Value.absent(), + Value icon = const Value.absent(), + Value siteLink = const Value.absent(), Value?> authors = const Value.absent(), Value?> tags = const Value.absent(), Value lastFetched = const Value.absent(), @@ -207,6 +263,8 @@ class FeedData extends DataClass implements Insertable { url: url ?? this.url, title: title.present ? title.value : this.title, description: description.present ? description.value : this.description, + icon: icon.present ? icon.value : this.icon, + siteLink: siteLink.present ? siteLink.value : this.siteLink, authors: authors.present ? authors.value : this.authors, tags: tags.present ? tags.value : this.tags, lastFetched: lastFetched.present ? lastFetched.value : this.lastFetched, @@ -217,6 +275,8 @@ class FeedData extends DataClass implements Insertable { title: data.title.present ? data.title.value : this.title, description: data.description.present ? data.description.value : this.description, + icon: data.icon.present ? data.icon.value : this.icon, + siteLink: data.siteLink.present ? data.siteLink.value : this.siteLink, authors: data.authors.present ? data.authors.value : this.authors, tags: data.tags.present ? data.tags.value : this.tags, lastFetched: @@ -230,6 +290,8 @@ class FeedData extends DataClass implements Insertable { ..write('url: $url, ') ..write('title: $title, ') ..write('description: $description, ') + ..write('icon: $icon, ') + ..write('siteLink: $siteLink, ') ..write('authors: $authors, ') ..write('tags: $tags, ') ..write('lastFetched: $lastFetched') @@ -238,8 +300,16 @@ class FeedData extends DataClass implements Insertable { } @override - int get hashCode => - Object.hash(url, title, description, authors, tags, lastFetched); + int get hashCode => Object.hash( + url, + title, + description, + icon, + siteLink, + authors, + tags, + lastFetched, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -247,6 +317,8 @@ class FeedData extends DataClass implements Insertable { other.url == this.url && other.title == this.title && other.description == this.description && + other.icon == this.icon && + other.siteLink == this.siteLink && other.authors == this.authors && other.tags == this.tags && other.lastFetched == this.lastFetched); @@ -256,6 +328,8 @@ class FeedCompanion extends UpdateCompanion { final Value url; final Value title; final Value description; + final Value icon; + final Value siteLink; final Value?> authors; final Value?> tags; final Value lastFetched; @@ -264,6 +338,8 @@ class FeedCompanion extends UpdateCompanion { this.url = const Value.absent(), this.title = const Value.absent(), this.description = const Value.absent(), + this.icon = const Value.absent(), + this.siteLink = const Value.absent(), this.authors = const Value.absent(), this.tags = const Value.absent(), this.lastFetched = const Value.absent(), @@ -273,6 +349,8 @@ class FeedCompanion extends UpdateCompanion { required Uri url, this.title = const Value.absent(), this.description = const Value.absent(), + this.icon = const Value.absent(), + this.siteLink = const Value.absent(), this.authors = const Value.absent(), this.tags = const Value.absent(), this.lastFetched = const Value.absent(), @@ -282,6 +360,8 @@ class FeedCompanion extends UpdateCompanion { Expression? url, Expression? title, Expression? description, + Expression? icon, + Expression? siteLink, Expression? authors, Expression? tags, Expression? lastFetched, @@ -291,6 +371,8 @@ class FeedCompanion extends UpdateCompanion { if (url != null) 'url': url, if (title != null) 'title': title, if (description != null) 'description': description, + if (icon != null) 'icon': icon, + if (siteLink != null) 'site_link': siteLink, if (authors != null) 'authors': authors, if (tags != null) 'tags': tags, if (lastFetched != null) 'last_fetched': lastFetched, @@ -302,6 +384,8 @@ class FeedCompanion extends UpdateCompanion { Value? url, Value? title, Value? description, + Value? icon, + Value? siteLink, Value?>? authors, Value?>? tags, Value? lastFetched, @@ -311,6 +395,8 @@ class FeedCompanion extends UpdateCompanion { url: url ?? this.url, title: title ?? this.title, description: description ?? this.description, + icon: icon ?? this.icon, + siteLink: siteLink ?? this.siteLink, authors: authors ?? this.authors, tags: tags ?? this.tags, lastFetched: lastFetched ?? this.lastFetched, @@ -330,6 +416,14 @@ class FeedCompanion extends UpdateCompanion { if (description.present) { map['description'] = Variable(description.value); } + if (icon.present) { + map['icon'] = Variable(Feed.$convertericonn.toSql(icon.value)); + } + if (siteLink.present) { + map['site_link'] = Variable( + Feed.$convertersiteLinkn.toSql(siteLink.value), + ); + } if (authors.present) { map['authors'] = Variable( Feed.$converterauthorsn.toSql(authors.value), @@ -353,6 +447,8 @@ class FeedCompanion extends UpdateCompanion { ..write('url: $url, ') ..write('title: $title, ') ..write('description: $description, ') + ..write('icon: $icon, ') + ..write('siteLink: $siteLink, ') ..write('authors: $authors, ') ..write('tags: $tags, ') ..write('lastFetched: $lastFetched, ') @@ -806,6 +902,226 @@ class ArticleCompanion extends UpdateCompanion { } } +class ArticleView extends ViewInfo + implements HasResultSet { + final String? _alias; + @override + final _$FeedDatabase attachedDatabase; + ArticleView(this.attachedDatabase, [this._alias]); + @override + List get $columns => [ + id, + feedId, + fetched, + created, + updated, + lastRead, + title, + authors, + tags, + links, + summaryMarkdown, + summaryPlain, + contentMarkdown, + contentPlain, + icon, + ]; + @override + String get aliasedName => _alias ?? entityName; + @override + String get entityName => 'article_view'; + @override + Map get createViewStatements => { + SqlDialect.sqlite: + 'CREATE VIEW article_view AS SELECT a.*, f.icon FROM article AS a INNER JOIN feed AS f ON f.url = a.feed_id', + }; + @override + ArticleView get asDslTable => this; + @override + FeedArticle map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return FeedArticle( + id: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + feedId: Article.$converterfeedId.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}feed_id'], + )!, + ), + fetched: + attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}fetched'], + )!, + created: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created'], + ), + updated: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated'], + ), + lastRead: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_read'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + authors: Article.$converterauthorsn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}authors'], + ), + ), + tags: Article.$convertertagsn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tags'], + ), + ), + links: Article.$converterlinksn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}links'], + ), + ), + summaryMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summaryMarkdown'], + ), + summaryPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summaryPlain'], + ), + contentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}contentMarkdown'], + ), + contentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}contentPlain'], + ), + icon: Feed.$convertericonn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}icon'], + ), + ), + ); + } + + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + ); + late final GeneratedColumnWithTypeConverter feedId = + GeneratedColumn( + 'feed_id', + aliasedName, + false, + type: DriftSqlType.string, + ).withConverter(Article.$converterfeedId); + late final GeneratedColumn fetched = GeneratedColumn( + 'fetched', + aliasedName, + false, + type: DriftSqlType.dateTime, + ); + late final GeneratedColumn created = GeneratedColumn( + 'created', + aliasedName, + true, + type: DriftSqlType.dateTime, + ); + late final GeneratedColumn updated = GeneratedColumn( + 'updated', + aliasedName, + true, + type: DriftSqlType.dateTime, + ); + late final GeneratedColumn lastRead = GeneratedColumn( + 'last_read', + aliasedName, + true, + type: DriftSqlType.dateTime, + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + ); + late final GeneratedColumnWithTypeConverter?, String> + authors = GeneratedColumn( + 'authors', + aliasedName, + true, + type: DriftSqlType.string, + ).withConverter?>(Article.$converterauthorsn); + late final GeneratedColumnWithTypeConverter?, String> + tags = GeneratedColumn( + 'tags', + aliasedName, + true, + type: DriftSqlType.string, + ).withConverter?>(Article.$convertertagsn); + late final GeneratedColumnWithTypeConverter?, String> links = + GeneratedColumn( + 'links', + aliasedName, + true, + type: DriftSqlType.string, + ).withConverter?>(Article.$converterlinksn); + late final GeneratedColumn summaryMarkdown = GeneratedColumn( + 'summaryMarkdown', + aliasedName, + true, + type: DriftSqlType.string, + ); + late final GeneratedColumn summaryPlain = GeneratedColumn( + 'summaryPlain', + aliasedName, + true, + type: DriftSqlType.string, + ); + late final GeneratedColumn contentMarkdown = GeneratedColumn( + 'contentMarkdown', + aliasedName, + true, + type: DriftSqlType.string, + ); + late final GeneratedColumn contentPlain = GeneratedColumn( + 'contentPlain', + aliasedName, + true, + type: DriftSqlType.string, + ); + late final GeneratedColumnWithTypeConverter icon = + GeneratedColumn( + 'icon', + aliasedName, + true, + type: DriftSqlType.string, + ).withConverter(Feed.$convertericonn); + @override + ArticleView createAlias(String alias) { + return ArticleView(attachedDatabase, alias); + } + + @override + Query? get query => null; + @override + Set get readTables => const {'article', 'feed'}; +} + class ArticleFts extends Table with TableInfo, @@ -1046,6 +1362,7 @@ abstract class _$FeedDatabase extends GeneratedDatabase { $FeedDatabaseManager get managers => $FeedDatabaseManager(this); late final Feed feed = Feed(this); late final Article article = Article(this); + late final ArticleView articleView = ArticleView(this); late final Index articleFeedId = Index( 'article_feed_id', 'CREATE INDEX article_feed_id ON article (feed_id)', @@ -1074,20 +1391,13 @@ abstract class _$FeedDatabase extends GeneratedDatabase { } Selectable queryArticlesBasic({ - required String beforeMatch, - required String afterMatch, required String query, String? feedId, }) { return customSelect( - 'WITH weights AS (SELECT 1.0 AS title_weight) SELECT a.*, highlight(article_fts, 0, ?1, ?2) AS title,(bm25(article_fts, weights.title_weight))AS weighted_rank FROM article_fts(?3)AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" CROSS JOIN weights WHERE fts.title LIKE ?3 AND ?4 IS NULL OR a.feed_id = ?4 ORDER BY weighted_rank ASC, a.created DESC NULLS LAST', - variables: [ - Variable(beforeMatch), - Variable(afterMatch), - Variable(query), - Variable(feedId), - ], - readsFrom: {articleFts, article}, + 'WITH weights AS (SELECT 1.0 AS title_weight) SELECT a.*, f.icon,(bm25(article_fts, weights.title_weight))AS weighted_rank FROM article_fts AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" INNER JOIN feed AS f ON f.url = a.feed_id CROSS JOIN weights WHERE fts.title LIKE ?1 AND(?2 IS NULL OR a.feed_id = ?2)ORDER BY weighted_rank ASC, a.created DESC NULLS LAST', + variables: [Variable(query), Variable(feedId)], + readsFrom: {feed, articleFts, article}, ).map( (QueryRow row) => FeedArticleQueryResult( id: row.read('id'), @@ -1114,6 +1424,10 @@ abstract class _$FeedDatabase extends GeneratedDatabase { summaryPlain: row.readNullable('summaryPlain'), contentMarkdown: row.readNullable('contentMarkdown'), contentPlain: row.readNullable('contentPlain'), + icon: NullAwareTypeConverter.wrapFromSql( + Feed.$convertericon, + row.readNullable('icon'), + ), ), ); } @@ -1127,7 +1441,7 @@ abstract class _$FeedDatabase extends GeneratedDatabase { String? feedId, }) { return customSelect( - 'WITH weights AS (SELECT 5.0 AS title_weight, 2.0 AS summary_weight, 1.0 AS content_weight) SELECT a.*, highlight(article_fts, 0, ?1, ?2) AS title, snippet(article_fts, 1, ?1, ?2, ?3, ?4) AS summary, snippet(article_fts, 2, ?1, ?2, ?3, ?4) AS content,(bm25(article_fts, weights.title_weight, weights.summary_weight, weights.content_weight))AS weighted_rank FROM article_fts(?5)AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" CROSS JOIN weights WHERE ?6 IS NULL OR a.feed_id = ?6 ORDER BY weighted_rank ASC, a.created DESC NULLS LAST', + 'WITH weights AS (SELECT 10.0 AS title_weight, 3.0 AS summary_weight, 1.0 AS content_weight) SELECT a.*, f.icon, highlight(article_fts, 0, ?1, ?2) AS title_highlight, snippet(article_fts, 1, ?1, ?2, ?3, ?4) AS summary_snippet, snippet(article_fts, 2, ?1, ?2, ?3, ?4) AS content_snippet,(bm25(article_fts, weights.title_weight, weights.summary_weight, weights.content_weight))AS weighted_rank FROM article_fts(?5)AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" INNER JOIN feed AS f ON f.url = a.feed_id CROSS JOIN weights WHERE ?6 IS NULL OR a.feed_id = ?6 ORDER BY weighted_rank ASC, a.created DESC NULLS LAST', variables: [ Variable(beforeMatch), Variable(afterMatch), @@ -1136,7 +1450,7 @@ abstract class _$FeedDatabase extends GeneratedDatabase { Variable(query), Variable(feedId), ], - readsFrom: {articleFts, article}, + readsFrom: {feed, articleFts, article}, ).map( (QueryRow row) => FeedArticleQueryResult( id: row.read('id'), @@ -1163,6 +1477,13 @@ abstract class _$FeedDatabase extends GeneratedDatabase { summaryPlain: row.readNullable('summaryPlain'), contentMarkdown: row.readNullable('contentMarkdown'), contentPlain: row.readNullable('contentPlain'), + icon: NullAwareTypeConverter.wrapFromSql( + Feed.$convertericon, + row.readNullable('icon'), + ), + titleHighlight: row.readNullable('title_highlight'), + summarySnippet: row.readNullable('summary_snippet'), + contentSnippet: row.readNullable('content_snippet'), ), ); } @@ -1174,6 +1495,7 @@ abstract class _$FeedDatabase extends GeneratedDatabase { List get allSchemaEntities => [ feed, article, + articleView, articleFeedId, articleFts, articleAfterInsert, @@ -1218,6 +1540,8 @@ typedef $FeedCreateCompanionBuilder = required Uri url, Value title, Value description, + Value icon, + Value siteLink, Value?> authors, Value?> tags, Value lastFetched, @@ -1228,6 +1552,8 @@ typedef $FeedUpdateCompanionBuilder = Value url, Value title, Value description, + Value icon, + Value siteLink, Value?> authors, Value?> tags, Value lastFetched, @@ -1282,6 +1608,18 @@ class $FeedFilterComposer extends Composer<_$FeedDatabase, Feed> { builder: (column) => ColumnFilters(column), ); + ColumnWithTypeConverterFilters get icon => + $composableBuilder( + column: $table.icon, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters get siteLink => + $composableBuilder( + column: $table.siteLink, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + ColumnWithTypeConverterFilters?, List, String> get authors => $composableBuilder( column: $table.authors, @@ -1352,6 +1690,16 @@ class $FeedOrderingComposer extends Composer<_$FeedDatabase, Feed> { builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get icon => $composableBuilder( + column: $table.icon, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get siteLink => $composableBuilder( + column: $table.siteLink, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get authors => $composableBuilder( column: $table.authors, builder: (column) => ColumnOrderings(column), @@ -1387,6 +1735,12 @@ class $FeedAnnotationComposer extends Composer<_$FeedDatabase, Feed> { builder: (column) => column, ); + GeneratedColumnWithTypeConverter get icon => + $composableBuilder(column: $table.icon, builder: (column) => column); + + GeneratedColumnWithTypeConverter get siteLink => + $composableBuilder(column: $table.siteLink, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get authors => $composableBuilder(column: $table.authors, builder: (column) => column); @@ -1455,6 +1809,8 @@ class $FeedTableManager Value url = const Value.absent(), Value title = const Value.absent(), Value description = const Value.absent(), + Value icon = const Value.absent(), + Value siteLink = const Value.absent(), Value?> authors = const Value.absent(), Value?> tags = const Value.absent(), Value lastFetched = const Value.absent(), @@ -1463,6 +1819,8 @@ class $FeedTableManager url: url, title: title, description: description, + icon: icon, + siteLink: siteLink, authors: authors, tags: tags, lastFetched: lastFetched, @@ -1473,6 +1831,8 @@ class $FeedTableManager required Uri url, Value title = const Value.absent(), Value description = const Value.absent(), + Value icon = const Value.absent(), + Value siteLink = const Value.absent(), Value?> authors = const Value.absent(), Value?> tags = const Value.absent(), Value lastFetched = const Value.absent(), @@ -1481,6 +1841,8 @@ class $FeedTableManager url: url, title: title, description: description, + icon: icon, + siteLink: siteLink, authors: authors, tags: tags, lastFetched: lastFetched, diff --git a/app/lib/features/web_feed/data/models/feed_article.dart b/app/lib/features/web_feed/data/models/feed_article.dart index f1361dbd..2d48fc9c 100644 --- a/app/lib/features/web_feed/data/models/feed_article.dart +++ b/app/lib/features/web_feed/data/models/feed_article.dart @@ -25,6 +25,9 @@ class FeedArticle with FastEquatable implements Insertable { final String? contentMarkdown; final String? contentPlain; + //Derived by view from feed table, should not get inserted + final Uri? icon; + FeedArticle({ required this.id, required this.feedId, @@ -40,6 +43,7 @@ class FeedArticle with FastEquatable implements Insertable { this.summaryPlain, this.contentMarkdown, this.contentPlain, + this.icon, }); factory FeedArticle.fromJson(Map json) => @@ -109,5 +113,6 @@ class FeedArticle with FastEquatable implements Insertable { summaryPlain, contentMarkdown, contentPlain, + icon, ]; } diff --git a/app/lib/features/web_feed/data/models/feed_article.g.dart b/app/lib/features/web_feed/data/models/feed_article.g.dart index 1d28976a..d12f8a72 100644 --- a/app/lib/features/web_feed/data/models/feed_article.g.dart +++ b/app/lib/features/web_feed/data/models/feed_article.g.dart @@ -39,6 +39,7 @@ FeedArticle _$FeedArticleFromJson(Map json) => FeedArticle( summaryPlain: json['summaryPlain'] as String?, contentMarkdown: json['contentMarkdown'] as String?, contentPlain: json['contentPlain'] as String?, + icon: json['icon'] == null ? null : Uri.parse(json['icon'] as String), ); Map _$FeedArticleToJson(FeedArticle instance) => @@ -57,4 +58,5 @@ Map _$FeedArticleToJson(FeedArticle instance) => 'summaryPlain': instance.summaryPlain, 'contentMarkdown': instance.contentMarkdown, 'contentPlain': instance.contentPlain, + 'icon': instance.icon?.toString(), }; diff --git a/app/lib/features/web_feed/data/models/feed_article_query_result.dart b/app/lib/features/web_feed/data/models/feed_article_query_result.dart index 30724f14..29347dc5 100644 --- a/app/lib/features/web_feed/data/models/feed_article_query_result.dart +++ b/app/lib/features/web_feed/data/models/feed_article_query_result.dart @@ -1,6 +1,10 @@ import 'package:lensai/features/web_feed/data/models/feed_article.dart'; class FeedArticleQueryResult extends FeedArticle { + final String? titleHighlight; + final String? summarySnippet; + final String? contentSnippet; + final double weightedRank; FeedArticleQueryResult({ @@ -8,19 +12,29 @@ class FeedArticleQueryResult extends FeedArticle { required super.feedId, required super.fetched, required this.weightedRank, - super.created, - super.updated, - super.lastRead, - super.title, - super.authors, - super.tags, - super.links, - super.summaryMarkdown, - super.summaryPlain, - super.contentMarkdown, - super.contentPlain, + required super.created, + required super.updated, + required super.lastRead, + required super.title, + required super.authors, + required super.tags, + required super.links, + required super.summaryMarkdown, + required super.summaryPlain, + required super.contentMarkdown, + required super.contentPlain, + required super.icon, + this.titleHighlight, + this.summarySnippet, + this.contentSnippet, }); @override - List get hashParameters => [...super.hashParameters, weightedRank]; + List get hashParameters => [ + ...super.hashParameters, + weightedRank, + summarySnippet, + contentSnippet, + titleHighlight, + ]; } diff --git a/app/lib/features/web_feed/data/models/feed_filter.dart b/app/lib/features/web_feed/data/models/feed_filter.dart deleted file mode 100644 index b64ef170..00000000 --- a/app/lib/features/web_feed/data/models/feed_filter.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:copy_with_extension/copy_with_extension.dart'; -import 'package:fast_equatable/fast_equatable.dart'; - -part 'feed_filter.g.dart'; - -@CopyWith() -class FeedFilter with FastEquatable { - final Uri? feedId; - final String? query; - final Set? tags; - - FeedFilter({this.feedId, this.query, this.tags}); - - @override - List get hashParameters => [feedId, query, tags]; -} diff --git a/app/lib/features/web_feed/data/models/feed_filter.g.dart b/app/lib/features/web_feed/data/models/feed_filter.g.dart deleted file mode 100644 index 0c258dd5..00000000 --- a/app/lib/features/web_feed/data/models/feed_filter.g.dart +++ /dev/null @@ -1,76 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'feed_filter.dart'; - -// ************************************************************************** -// CopyWithGenerator -// ************************************************************************** - -abstract class _$FeedFilterCWProxy { - FeedFilter feedId(Uri? feedId); - - FeedFilter query(String? query); - - FeedFilter tags(Set? tags); - - /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `FeedFilter(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. - /// - /// Usage - /// ```dart - /// FeedFilter(...).copyWith(id: 12, name: "My name") - /// ```` - FeedFilter call({Uri? feedId, String? query, Set? tags}); -} - -/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfFeedFilter.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfFeedFilter.copyWith.fieldName(...)` -class _$FeedFilterCWProxyImpl implements _$FeedFilterCWProxy { - const _$FeedFilterCWProxyImpl(this._value); - - final FeedFilter _value; - - @override - FeedFilter feedId(Uri? feedId) => this(feedId: feedId); - - @override - FeedFilter query(String? query) => this(query: query); - - @override - FeedFilter tags(Set? tags) => this(tags: tags); - - @override - /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `FeedFilter(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. - /// - /// Usage - /// ```dart - /// FeedFilter(...).copyWith(id: 12, name: "My name") - /// ```` - FeedFilter call({ - Object? feedId = const $CopyWithPlaceholder(), - Object? query = const $CopyWithPlaceholder(), - Object? tags = const $CopyWithPlaceholder(), - }) { - return FeedFilter( - feedId: - feedId == const $CopyWithPlaceholder() - ? _value.feedId - // ignore: cast_nullable_to_non_nullable - : feedId as Uri?, - query: - query == const $CopyWithPlaceholder() - ? _value.query - // ignore: cast_nullable_to_non_nullable - : query as String?, - tags: - tags == const $CopyWithPlaceholder() - ? _value.tags - // ignore: cast_nullable_to_non_nullable - : tags as Set?, - ); - } -} - -extension $FeedFilterCopyWith on FeedFilter { - /// Returns a callable class that can be used as follows: `instanceOfFeedFilter.copyWith(...)` or like so:`instanceOfFeedFilter.copyWith.fieldName(...)`. - // ignore: library_private_types_in_public_api - _$FeedFilterCWProxy get copyWith => _$FeedFilterCWProxyImpl(this); -} diff --git a/app/lib/features/web_feed/domain/providers.dart b/app/lib/features/web_feed/domain/providers.dart index 5f77687e..75be22da 100644 --- a/app/lib/features/web_feed/domain/providers.dart +++ b/app/lib/features/web_feed/domain/providers.dart @@ -1,12 +1,62 @@ +import 'dart:async'; + +import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/features/web_feed/data/database/database.dart'; -import 'package:lensai/features/web_feed/data/models/feed_filter.dart'; import 'package:lensai/features/web_feed/data/models/feed_article.dart'; +import 'package:lensai/features/web_feed/data/models/feed_parse_result.dart'; +import 'package:lensai/features/web_feed/data/providers.dart'; +import 'package:lensai/features/web_feed/domain/providers/article_filter.dart'; import 'package:lensai/features/web_feed/domain/repositories/feed_repository.dart'; +import 'package:lensai/features/web_feed/domain/services/feed_reader.dart'; import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'providers.g.dart'; +@Riverpod() +class ArticleSearch extends _$ArticleSearch { + late StreamController> _streamController; + + Future search( + String input, { + int snippetLength = 120, + String matchPrefix = '***', + String matchSuffix = '***', + String ellipsis = '…', + }) async { + if (input.isNotEmpty) { + await ref + .read(feedDatabaseProvider) + .articleDao + .queryArticles( + matchPrefix: matchPrefix, + matchSuffix: matchSuffix, + ellipsis: ellipsis, + snippetLength: snippetLength, + searchString: input, + feedId: feedId, + ) + .get() + .then((value) { + if (!_streamController.isClosed) { + _streamController.add(value); + } + }); + } + } + + @override + Stream> build(Uri? feedId) { + _streamController = StreamController(); + + ref.onDispose(() async { + await _streamController.close(); + }); + + return _streamController.stream; + } +} + @Riverpod() Stream> feedList(Ref ref) { final repository = ref.watch(feedRepositoryProvider.notifier); @@ -14,25 +64,102 @@ Stream> feedList(Ref ref) { } @Riverpod() -Stream> feedArticleList(Ref ref, FeedFilter filter) { +Stream feedData(Ref ref, Uri? feedId) { final repository = ref.watch(feedRepositoryProvider.notifier); - return repository.watchFeedArticles(filter); + + if (feedId == null) { + return Stream.value(null); + } + + return repository.watchFeed(feedId); } @Riverpod() -Stream feedArticle(Ref ref, String articleId) { +Stream> feedArticleList(Ref ref, Uri? feedId) { final repository = ref.watch(feedRepositoryProvider.notifier); - return repository.watchArticle(articleId); + return repository.watchFeedArticles(feedId); } @Riverpod() -Raw>> _unreadArticleCount(Ref ref) { +class FilteredArticleList extends _$FilteredArticleList { + bool _hasSearch = false; + + void search(String input) { + if (input.isNotEmpty) { + if (!_hasSearch) { + _hasSearch = true; + ref.invalidateSelf(); + } + + //Don't block + unawaited(ref.read(articleSearchProvider(feedId).notifier).search(input)); + } else if (_hasSearch) { + _hasSearch = false; + ref.invalidateSelf(); + } + } + + @override + AsyncValue> build(Uri? feedId) { + final filterTags = ref.watch(articleFilterProvider); + + final articlesAsync = + _hasSearch + ? ref.watch(articleSearchProvider(feedId)) + : ref.watch(feedArticleListProvider(feedId)); + + return articlesAsync.whenData((articles) { + if (filterTags.isNotEmpty) { + return articles.where((article) { + final tags = article.tags?.map((tag) => tag.id).toSet(); + + final authors = + article.authors + ?.map((author) => author.name.whenNotEmpty) + .nonNulls + .toSet(); + + return filterTags.every( + (filter) => + (tags?.contains(filter) ?? false) || + (authors?.contains(filter) ?? false), + ); + }).toList(); + } + + return articles; + }); + } +} + +@Riverpod() +Stream feedArticle( + Ref ref, + String articleId, { + required bool updateReadDate, +}) async* { + final repository = ref.watch(feedRepositoryProvider.notifier); + + if (updateReadDate) { + await repository.touchArticleRead(articleId); + } + + yield* repository.watchArticle(articleId); +} + +@Riverpod() +Raw>> unreadArticleCount(Ref ref) { final repository = ref.watch(feedRepositoryProvider.notifier); return repository.watchUnreadFeedArticleCount(); } @Riverpod() Stream unreadFeedArticleCount(Ref ref, Uri feedId) { - final stream = ref.watch(_unreadArticleCountProvider); + final stream = ref.watch(unreadArticleCountProvider); return stream.map((counts) => counts[feedId.toString()]); } + +@Riverpod() +Future fetchWebFeed(Ref ref, Uri url) { + return ref.read(feedReaderProvider.notifier).parseFeed(url); +} diff --git a/app/lib/features/web_feed/domain/providers.g.dart b/app/lib/features/web_feed/domain/providers.g.dart index ca6c9d12..8f47cf92 100644 --- a/app/lib/features/web_feed/domain/providers.g.dart +++ b/app/lib/features/web_feed/domain/providers.g.dart @@ -22,7 +22,7 @@ final feedListProvider = AutoDisposeStreamProvider>.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef FeedListRef = AutoDisposeStreamProviderRef>; -String _$feedArticleListHash() => r'64e834a1b69d913f1c860b38956b69af9e89a833'; +String _$feedDataHash() => r'0599a2e3d159ef3abb6c3d2c871f87da2f5e646b'; /// Copied from Dart SDK class _SystemHash { @@ -45,6 +45,124 @@ class _SystemHash { } } +/// See also [feedData]. +@ProviderFor(feedData) +const feedDataProvider = FeedDataFamily(); + +/// See also [feedData]. +class FeedDataFamily extends Family> { + /// See also [feedData]. + const FeedDataFamily(); + + /// See also [feedData]. + FeedDataProvider call(Uri? feedId) { + return FeedDataProvider(feedId); + } + + @override + FeedDataProvider getProviderOverride(covariant FeedDataProvider provider) { + return call(provider.feedId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'feedDataProvider'; +} + +/// See also [feedData]. +class FeedDataProvider extends AutoDisposeStreamProvider { + /// See also [feedData]. + FeedDataProvider(Uri? feedId) + : this._internal( + (ref) => feedData(ref as FeedDataRef, feedId), + from: feedDataProvider, + name: r'feedDataProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$feedDataHash, + dependencies: FeedDataFamily._dependencies, + allTransitiveDependencies: FeedDataFamily._allTransitiveDependencies, + feedId: feedId, + ); + + FeedDataProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.feedId, + }) : super.internal(); + + final Uri? feedId; + + @override + Override overrideWith( + Stream Function(FeedDataRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: FeedDataProvider._internal( + (ref) => create(ref as FeedDataRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + feedId: feedId, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _FeedDataProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FeedDataProvider && other.feedId == feedId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, feedId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin FeedDataRef on AutoDisposeStreamProviderRef { + /// The parameter `feedId` of this provider. + Uri? get feedId; +} + +class _FeedDataProviderElement + extends AutoDisposeStreamProviderElement + with FeedDataRef { + _FeedDataProviderElement(super.provider); + + @override + Uri? get feedId => (origin as FeedDataProvider).feedId; +} + +String _$feedArticleListHash() => r'45d585cc9f59ad48a0d1d6fbcf802b1c7de7f6bc'; + /// See also [feedArticleList]. @ProviderFor(feedArticleList) const feedArticleListProvider = FeedArticleListFamily(); @@ -55,15 +173,15 @@ class FeedArticleListFamily extends Family>> { const FeedArticleListFamily(); /// See also [feedArticleList]. - FeedArticleListProvider call(FeedFilter filter) { - return FeedArticleListProvider(filter); + FeedArticleListProvider call(Uri? feedId) { + return FeedArticleListProvider(feedId); } @override FeedArticleListProvider getProviderOverride( covariant FeedArticleListProvider provider, ) { - return call(provider.filter); + return call(provider.feedId); } static const Iterable? _dependencies = null; @@ -85,9 +203,9 @@ class FeedArticleListFamily extends Family>> { class FeedArticleListProvider extends AutoDisposeStreamProvider> { /// See also [feedArticleList]. - FeedArticleListProvider(FeedFilter filter) + FeedArticleListProvider(Uri? feedId) : this._internal( - (ref) => feedArticleList(ref as FeedArticleListRef, filter), + (ref) => feedArticleList(ref as FeedArticleListRef, feedId), from: feedArticleListProvider, name: r'feedArticleListProvider', debugGetCreateSourceHash: @@ -97,7 +215,7 @@ class FeedArticleListProvider dependencies: FeedArticleListFamily._dependencies, allTransitiveDependencies: FeedArticleListFamily._allTransitiveDependencies, - filter: filter, + feedId: feedId, ); FeedArticleListProvider._internal( @@ -107,10 +225,10 @@ class FeedArticleListProvider required super.allTransitiveDependencies, required super.debugGetCreateSourceHash, required super.from, - required this.filter, + required this.feedId, }) : super.internal(); - final FeedFilter filter; + final Uri? feedId; @override Override overrideWith( @@ -125,7 +243,7 @@ class FeedArticleListProvider dependencies: null, allTransitiveDependencies: null, debugGetCreateSourceHash: null, - filter: filter, + feedId: feedId, ), ); } @@ -137,13 +255,13 @@ class FeedArticleListProvider @override bool operator ==(Object other) { - return other is FeedArticleListProvider && other.filter == filter; + return other is FeedArticleListProvider && other.feedId == feedId; } @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, filter.hashCode); + hash = _SystemHash.combine(hash, feedId.hashCode); return _SystemHash.finish(hash); } @@ -152,8 +270,8 @@ class FeedArticleListProvider @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element mixin FeedArticleListRef on AutoDisposeStreamProviderRef> { - /// The parameter `filter` of this provider. - FeedFilter get filter; + /// The parameter `feedId` of this provider. + Uri? get feedId; } class _FeedArticleListProviderElement @@ -162,10 +280,10 @@ class _FeedArticleListProviderElement _FeedArticleListProviderElement(super.provider); @override - FeedFilter get filter => (origin as FeedArticleListProvider).filter; + Uri? get feedId => (origin as FeedArticleListProvider).feedId; } -String _$feedArticleHash() => r'b1670f2ce11636f42fc0595befe42ada19319880'; +String _$feedArticleHash() => r'18b5faf391867b95f4b3bc4f74a6083854b633a5'; /// See also [feedArticle]. @ProviderFor(feedArticle) @@ -177,15 +295,15 @@ class FeedArticleFamily extends Family> { const FeedArticleFamily(); /// See also [feedArticle]. - FeedArticleProvider call(String articleId) { - return FeedArticleProvider(articleId); + FeedArticleProvider call(String articleId, {required bool updateReadDate}) { + return FeedArticleProvider(articleId, updateReadDate: updateReadDate); } @override FeedArticleProvider getProviderOverride( covariant FeedArticleProvider provider, ) { - return call(provider.articleId); + return call(provider.articleId, updateReadDate: provider.updateReadDate); } static const Iterable? _dependencies = null; @@ -206,9 +324,13 @@ class FeedArticleFamily extends Family> { /// See also [feedArticle]. class FeedArticleProvider extends AutoDisposeStreamProvider { /// See also [feedArticle]. - FeedArticleProvider(String articleId) + FeedArticleProvider(String articleId, {required bool updateReadDate}) : this._internal( - (ref) => feedArticle(ref as FeedArticleRef, articleId), + (ref) => feedArticle( + ref as FeedArticleRef, + articleId, + updateReadDate: updateReadDate, + ), from: feedArticleProvider, name: r'feedArticleProvider', debugGetCreateSourceHash: @@ -218,6 +340,7 @@ class FeedArticleProvider extends AutoDisposeStreamProvider { dependencies: FeedArticleFamily._dependencies, allTransitiveDependencies: FeedArticleFamily._allTransitiveDependencies, articleId: articleId, + updateReadDate: updateReadDate, ); FeedArticleProvider._internal( @@ -228,9 +351,11 @@ class FeedArticleProvider extends AutoDisposeStreamProvider { required super.debugGetCreateSourceHash, required super.from, required this.articleId, + required this.updateReadDate, }) : super.internal(); final String articleId; + final bool updateReadDate; @override Override overrideWith( @@ -246,6 +371,7 @@ class FeedArticleProvider extends AutoDisposeStreamProvider { allTransitiveDependencies: null, debugGetCreateSourceHash: null, articleId: articleId, + updateReadDate: updateReadDate, ), ); } @@ -257,13 +383,16 @@ class FeedArticleProvider extends AutoDisposeStreamProvider { @override bool operator ==(Object other) { - return other is FeedArticleProvider && other.articleId == articleId; + return other is FeedArticleProvider && + other.articleId == articleId && + other.updateReadDate == updateReadDate; } @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); hash = _SystemHash.combine(hash, articleId.hashCode); + hash = _SystemHash.combine(hash, updateReadDate.hashCode); return _SystemHash.finish(hash); } @@ -274,6 +403,9 @@ class FeedArticleProvider extends AutoDisposeStreamProvider { mixin FeedArticleRef on AutoDisposeStreamProviderRef { /// The parameter `articleId` of this provider. String get articleId; + + /// The parameter `updateReadDate` of this provider. + bool get updateReadDate; } class _FeedArticleProviderElement @@ -283,17 +415,19 @@ class _FeedArticleProviderElement @override String get articleId => (origin as FeedArticleProvider).articleId; + @override + bool get updateReadDate => (origin as FeedArticleProvider).updateReadDate; } String _$unreadArticleCountHash() => - r'6fb96215fb3b7739a6cbd594a0358415ced04fca'; + r'709518ad229636df0f1095f47e3a6d116b3aa7e6'; -/// See also [_unreadArticleCount]. -@ProviderFor(_unreadArticleCount) -final _unreadArticleCountProvider = +/// See also [unreadArticleCount]. +@ProviderFor(unreadArticleCount) +final unreadArticleCountProvider = AutoDisposeProvider>>>.internal( - _unreadArticleCount, - name: r'_unreadArticleCountProvider', + unreadArticleCount, + name: r'unreadArticleCountProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null @@ -304,10 +438,10 @@ final _unreadArticleCountProvider = @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef _UnreadArticleCountRef = +typedef UnreadArticleCountRef = AutoDisposeProviderRef>>>; String _$unreadFeedArticleCountHash() => - r'f8573674477f0d9813b42c82d75e8196c1c1445b'; + r'5e0d8e58b3d1dec978dc07b22367f2cb037b1b15'; /// See also [unreadFeedArticleCount]. @ProviderFor(unreadFeedArticleCount) @@ -429,5 +563,416 @@ class _UnreadFeedArticleCountProviderElement Uri get feedId => (origin as UnreadFeedArticleCountProvider).feedId; } +String _$fetchWebFeedHash() => r'73bdf87ad7dbd039c7dc181d80acdf99d96fe1c6'; + +/// See also [fetchWebFeed]. +@ProviderFor(fetchWebFeed) +const fetchWebFeedProvider = FetchWebFeedFamily(); + +/// See also [fetchWebFeed]. +class FetchWebFeedFamily extends Family> { + /// See also [fetchWebFeed]. + const FetchWebFeedFamily(); + + /// See also [fetchWebFeed]. + FetchWebFeedProvider call(Uri url) { + return FetchWebFeedProvider(url); + } + + @override + FetchWebFeedProvider getProviderOverride( + covariant FetchWebFeedProvider provider, + ) { + return call(provider.url); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'fetchWebFeedProvider'; +} + +/// See also [fetchWebFeed]. +class FetchWebFeedProvider extends AutoDisposeFutureProvider { + /// See also [fetchWebFeed]. + FetchWebFeedProvider(Uri url) + : this._internal( + (ref) => fetchWebFeed(ref as FetchWebFeedRef, url), + from: fetchWebFeedProvider, + name: r'fetchWebFeedProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$fetchWebFeedHash, + dependencies: FetchWebFeedFamily._dependencies, + allTransitiveDependencies: + FetchWebFeedFamily._allTransitiveDependencies, + url: url, + ); + + FetchWebFeedProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.url, + }) : super.internal(); + + final Uri url; + + @override + Override overrideWith( + FutureOr Function(FetchWebFeedRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: FetchWebFeedProvider._internal( + (ref) => create(ref as FetchWebFeedRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + url: url, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _FetchWebFeedProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FetchWebFeedProvider && other.url == url; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, url.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin FetchWebFeedRef on AutoDisposeFutureProviderRef { + /// The parameter `url` of this provider. + Uri get url; +} + +class _FetchWebFeedProviderElement + extends AutoDisposeFutureProviderElement + with FetchWebFeedRef { + _FetchWebFeedProviderElement(super.provider); + + @override + Uri get url => (origin as FetchWebFeedProvider).url; +} + +String _$articleSearchHash() => r'8bf2c4aa8d8b3918be8a416909535682abfbab35'; + +abstract class _$ArticleSearch + extends BuildlessAutoDisposeStreamNotifier> { + late final Uri? feedId; + + Stream> build(Uri? feedId); +} + +/// See also [ArticleSearch]. +@ProviderFor(ArticleSearch) +const articleSearchProvider = ArticleSearchFamily(); + +/// See also [ArticleSearch]. +class ArticleSearchFamily extends Family>> { + /// See also [ArticleSearch]. + const ArticleSearchFamily(); + + /// See also [ArticleSearch]. + ArticleSearchProvider call(Uri? feedId) { + return ArticleSearchProvider(feedId); + } + + @override + ArticleSearchProvider getProviderOverride( + covariant ArticleSearchProvider provider, + ) { + return call(provider.feedId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'articleSearchProvider'; +} + +/// See also [ArticleSearch]. +class ArticleSearchProvider + extends + AutoDisposeStreamNotifierProviderImpl< + ArticleSearch, + List + > { + /// See also [ArticleSearch]. + ArticleSearchProvider(Uri? feedId) + : this._internal( + () => ArticleSearch()..feedId = feedId, + from: articleSearchProvider, + name: r'articleSearchProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$articleSearchHash, + dependencies: ArticleSearchFamily._dependencies, + allTransitiveDependencies: + ArticleSearchFamily._allTransitiveDependencies, + feedId: feedId, + ); + + ArticleSearchProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.feedId, + }) : super.internal(); + + final Uri? feedId; + + @override + Stream> runNotifierBuild(covariant ArticleSearch notifier) { + return notifier.build(feedId); + } + + @override + Override overrideWith(ArticleSearch Function() create) { + return ProviderOverride( + origin: this, + override: ArticleSearchProvider._internal( + () => create()..feedId = feedId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + feedId: feedId, + ), + ); + } + + @override + AutoDisposeStreamNotifierProviderElement> + createElement() { + return _ArticleSearchProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ArticleSearchProvider && other.feedId == feedId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, feedId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ArticleSearchRef + on AutoDisposeStreamNotifierProviderRef> { + /// The parameter `feedId` of this provider. + Uri? get feedId; +} + +class _ArticleSearchProviderElement + extends + AutoDisposeStreamNotifierProviderElement< + ArticleSearch, + List + > + with ArticleSearchRef { + _ArticleSearchProviderElement(super.provider); + + @override + Uri? get feedId => (origin as ArticleSearchProvider).feedId; +} + +String _$filteredArticleListHash() => + r'4443a4a92cab0c8f534a74a1cba681a8973b058e'; + +abstract class _$FilteredArticleList + extends BuildlessAutoDisposeNotifier>> { + late final Uri? feedId; + + AsyncValue> build(Uri? feedId); +} + +/// See also [FilteredArticleList]. +@ProviderFor(FilteredArticleList) +const filteredArticleListProvider = FilteredArticleListFamily(); + +/// See also [FilteredArticleList]. +class FilteredArticleListFamily extends Family>> { + /// See also [FilteredArticleList]. + const FilteredArticleListFamily(); + + /// See also [FilteredArticleList]. + FilteredArticleListProvider call(Uri? feedId) { + return FilteredArticleListProvider(feedId); + } + + @override + FilteredArticleListProvider getProviderOverride( + covariant FilteredArticleListProvider provider, + ) { + return call(provider.feedId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'filteredArticleListProvider'; +} + +/// See also [FilteredArticleList]. +class FilteredArticleListProvider + extends + AutoDisposeNotifierProviderImpl< + FilteredArticleList, + AsyncValue> + > { + /// See also [FilteredArticleList]. + FilteredArticleListProvider(Uri? feedId) + : this._internal( + () => FilteredArticleList()..feedId = feedId, + from: filteredArticleListProvider, + name: r'filteredArticleListProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$filteredArticleListHash, + dependencies: FilteredArticleListFamily._dependencies, + allTransitiveDependencies: + FilteredArticleListFamily._allTransitiveDependencies, + feedId: feedId, + ); + + FilteredArticleListProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.feedId, + }) : super.internal(); + + final Uri? feedId; + + @override + AsyncValue> runNotifierBuild( + covariant FilteredArticleList notifier, + ) { + return notifier.build(feedId); + } + + @override + Override overrideWith(FilteredArticleList Function() create) { + return ProviderOverride( + origin: this, + override: FilteredArticleListProvider._internal( + () => create()..feedId = feedId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + feedId: feedId, + ), + ); + } + + @override + AutoDisposeNotifierProviderElement< + FilteredArticleList, + AsyncValue> + > + createElement() { + return _FilteredArticleListProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FilteredArticleListProvider && other.feedId == feedId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, feedId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin FilteredArticleListRef + on AutoDisposeNotifierProviderRef>> { + /// The parameter `feedId` of this provider. + Uri? get feedId; +} + +class _FilteredArticleListProviderElement + extends + AutoDisposeNotifierProviderElement< + FilteredArticleList, + AsyncValue> + > + with FilteredArticleListRef { + _FilteredArticleListProviderElement(super.provider); + + @override + Uri? get feedId => (origin as FilteredArticleListProvider).feedId; +} + // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/domain/providers/article_filter.dart b/app/lib/features/web_feed/domain/providers/article_filter.dart index 7a53b5f6..77e61e82 100644 --- a/app/lib/features/web_feed/domain/providers/article_filter.dart +++ b/app/lib/features/web_feed/domain/providers/article_filter.dart @@ -1,5 +1,3 @@ -import 'package:lensai/extensions/nullable.dart'; -import 'package:lensai/features/web_feed/data/models/feed_filter.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'article_filter.g.dart'; @@ -7,20 +5,17 @@ part 'article_filter.g.dart'; @Riverpod(keepAlive: true) class ArticleFilter extends _$ArticleFilter { void addTag(String tagId) { - final tags = {...?state.tags, tagId}; - - state = state.copyWith.tags(tags); + state = {...state, tagId}; } void removeTag(String tagId) { - if (state.tags.isNotEmpty) { - final tags = {...state.tags!}..remove(tagId); - state = state.copyWith.tags(tags); + if (state.isNotEmpty) { + state = {...state}..remove(tagId); } } @override - FeedFilter build() { - return FeedFilter(); + Set build() { + return {}; } } diff --git a/app/lib/features/web_feed/domain/providers/article_filter.g.dart b/app/lib/features/web_feed/domain/providers/article_filter.g.dart index b9747502..3947518a 100644 --- a/app/lib/features/web_feed/domain/providers/article_filter.g.dart +++ b/app/lib/features/web_feed/domain/providers/article_filter.g.dart @@ -6,12 +6,12 @@ part of 'article_filter.dart'; // RiverpodGenerator // ************************************************************************** -String _$articleFilterHash() => r'dfc997af8a33cbcb995288ef633ff321e95bae8d'; +String _$articleFilterHash() => r'61e4d5230e214038e753bc173158ed1d4dc57040'; /// See also [ArticleFilter]. @ProviderFor(ArticleFilter) final articleFilterProvider = - NotifierProvider.internal( + NotifierProvider>.internal( ArticleFilter.new, name: r'articleFilterProvider', debugGetCreateSourceHash: @@ -22,6 +22,6 @@ final articleFilterProvider = allTransitiveDependencies: null, ); -typedef _$ArticleFilter = Notifier; +typedef _$ArticleFilter = Notifier>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/domain/repositories/feed_repository.dart b/app/lib/features/web_feed/domain/repositories/feed_repository.dart index cbc659c1..02773b2a 100644 --- a/app/lib/features/web_feed/domain/repositories/feed_repository.dart +++ b/app/lib/features/web_feed/domain/repositories/feed_repository.dart @@ -1,6 +1,4 @@ -import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/features/web_feed/data/database/database.dart'; -import 'package:lensai/features/web_feed/data/models/feed_filter.dart'; import 'package:lensai/features/web_feed/data/models/feed_article.dart'; import 'package:lensai/features/web_feed/data/providers.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -13,11 +11,11 @@ class FeedRepository extends _$FeedRepository { return ref.read(feedDatabaseProvider).feedDao.getFeeds().get(); } - Future touchFeedFetched(Uri url) { + Future touchFeedFetched(Uri feedId) { return ref .read(feedDatabaseProvider) .feedDao - .updateFeedFetched(url, DateTime.now()); + .updateFeedFetched(feedId, DateTime.now()); } Future upsertFeed(FeedData feedData) { @@ -28,8 +26,8 @@ class FeedRepository extends _$FeedRepository { return ref.read(feedDatabaseProvider).articleDao.upsertArticles(articles); } - Future deleteFeed(Uri url) { - return ref.read(feedDatabaseProvider).feedDao.deleteFeed(url); + Future deleteFeed(Uri feedId) { + return ref.read(feedDatabaseProvider).feedDao.deleteFeed(feedId); } Future touchArticleRead(String articleId) { @@ -50,46 +48,20 @@ class FeedRepository extends _$FeedRepository { return ref.read(feedDatabaseProvider).feedDao.getFeeds().watch(); } - Stream> watchFeedArticles( - FeedFilter filter, { - int snippetLength = 120, - String matchPrefix = '***', - String matchSuffix = '***', - String ellipsis = '…', - }) { - final stream = - filter.query.isNotEmpty - ? ref - .read(feedDatabaseProvider) - .articleDao - .queryArticles( - matchPrefix: matchPrefix, - matchSuffix: matchSuffix, - ellipsis: ellipsis, - snippetLength: snippetLength, - searchString: filter.query!, - feedId: filter.feedId, - ) - .watch() - : ref - .read(feedDatabaseProvider) - .articleDao - .getFeedArticles(filter.feedId) - .watch(); + Stream watchFeed(Uri feedId) { + return ref + .read(feedDatabaseProvider) + .feedDao + .getFeed(feedId) + .watchSingleOrNull(); + } - if (filter.tags.isNotEmpty) { - return stream.map( - (articles) => - articles - .where( - (article) => - article.tags?.toSet().containsAll(filter.tags!) ?? false, - ) - .toList(), - ); - } else { - return stream; - } + Stream> watchFeedArticles(Uri? feedId) { + return ref + .read(feedDatabaseProvider) + .articleDao + .getFeedArticles(feedId) + .watch(); } Stream watchArticle(String articleId) { diff --git a/app/lib/features/web_feed/domain/repositories/feed_repository.g.dart b/app/lib/features/web_feed/domain/repositories/feed_repository.g.dart index aafa5cbb..2356612a 100644 --- a/app/lib/features/web_feed/domain/repositories/feed_repository.g.dart +++ b/app/lib/features/web_feed/domain/repositories/feed_repository.g.dart @@ -6,7 +6,7 @@ part of 'feed_repository.dart'; // RiverpodGenerator // ************************************************************************** -String _$feedRepositoryHash() => r'6a050933a6a4eafbe76a66262e1b64bd838a1078'; +String _$feedRepositoryHash() => r'805cc26890b0d43576a1eab0ecb5851b6649d7b7'; /// See also [FeedRepository]. @ProviderFor(FeedRepository) diff --git a/app/lib/features/web_feed/extensions/atom.dart b/app/lib/features/web_feed/extensions/atom.dart index 55db93e9..2e0ee441 100644 --- a/app/lib/features/web_feed/extensions/atom.dart +++ b/app/lib/features/web_feed/extensions/atom.dart @@ -26,6 +26,12 @@ extension ParseAtomLink on List { } } +extension SelectFeedLink on List { + FeedLink? getRelation(FeedLinkRelation relation) { + return firstWhereOrNull((link) => link.relation == relation); + } +} + extension ParseAtomCategory on List { List toFeedCategories() { return where((category) => category.term.isNotEmpty) diff --git a/app/lib/features/web_feed/extensions/feed_article.dart b/app/lib/features/web_feed/extensions/feed_article.dart index 92b0e5a9..8fbfe9ef 100644 --- a/app/lib/features/web_feed/extensions/feed_article.dart +++ b/app/lib/features/web_feed/extensions/feed_article.dart @@ -1,15 +1,13 @@ -import 'package:collection/collection.dart'; import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/features/web_feed/data/models/feed_article.dart'; import 'package:lensai/features/web_feed/data/models/feed_link.dart'; +import 'package:lensai/features/web_feed/extensions/atom.dart'; extension FeedArticleX on FeedArticle { String get displayTitle => title ?? links - ?.firstWhereOrNull( - (link) => link.relation == FeedLinkRelation.alternate, - ) + ?.getRelation(FeedLinkRelation.alternate) .mapNotNull( (link) => link.title.whenNotEmpty ?? link.uri.toString(), ) ?? diff --git a/app/lib/features/web_feed/presentation/add_feed_dialog.dart b/app/lib/features/web_feed/presentation/add_feed_dialog.dart new file mode 100644 index 00000000..ecefb072 --- /dev/null +++ b/app/lib/features/web_feed/presentation/add_feed_dialog.dart @@ -0,0 +1,60 @@ +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/utils/form_validators.dart'; +import 'package:lensai/utils/uri_parser.dart' as uri_parser; + +class AddFeedDialog extends HookConsumerWidget { + final Uri? initialUri; + + const AddFeedDialog({super.key, required this.initialUri}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(() => GlobalKey()); + + final textController = useTextEditingController( + text: initialUri?.toString(), + ); + + return AlertDialog( + title: const Text('Add Feed'), + // contentPadding: const EdgeInsets.fromLTRB(24.0, 12.0, 24.0, 16.0), + content: Form( + key: formKey, + child: TextFormField( + decoration: const InputDecoration( + label: Text('URL'), + hintText: 'https://example.com/feed', + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + controller: textController, + keyboardType: TextInputType.url, + validator: (value) { + return validateUrl(value, onlyHttpProtocol: true); + }, + ), + ), + actions: [ + TextButton( + onPressed: () { + context.pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + if (formKey.currentState?.validate() == true) { + FeedCreateRoute( + feedId: uri_parser.tryParseUrl(textController.text)!, + ).pushReplacement(context); + } + }, + child: const Text('Add'), + ), + ], + ); + } +} diff --git a/app/lib/features/web_feed/presentation/screens/feed_article.dart b/app/lib/features/web_feed/presentation/screens/feed_article.dart index 3101c19f..f7482e3d 100644 --- a/app/lib/features/web_feed/presentation/screens/feed_article.dart +++ b/app/lib/features/web_feed/presentation/screens/feed_article.dart @@ -2,7 +2,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/core/providers/format.dart'; import 'package:lensai/core/routing/routes.dart'; @@ -10,6 +9,7 @@ import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/features/geckoview/domain/repositories/tab.dart'; import 'package:lensai/features/web_feed/data/models/feed_link.dart'; import 'package:lensai/features/web_feed/domain/providers.dart'; +import 'package:lensai/features/web_feed/extensions/atom.dart'; import 'package:lensai/features/web_feed/extensions/feed_article.dart'; import 'package:lensai/features/web_feed/presentation/widgets/authors_horizontal_list.dart'; import 'package:lensai/features/web_feed/presentation/widgets/tags_horizontal_list.dart'; @@ -26,13 +26,16 @@ class FeedArticleScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final articleAsync = ref.watch(feedArticleProvider(articleId)); + final articleAsync = ref.watch( + feedArticleProvider(articleId, updateReadDate: true), + ); return Scaffold( body: articleAsync.when( + skipLoadingOnReload: true, data: (article) { if (article == null) { - return SizedBox.shrink(); + return const SizedBox.shrink(); } return HookBuilder( @@ -55,9 +58,7 @@ class FeedArticleScreen extends HookConsumerWidget { ); final articleLink = useMemoized( - () => article.links?.firstWhereOrNull( - (link) => link.relation == FeedLinkRelation.alternate, - ), + () => article.links?.getRelation(FeedLinkRelation.alternate), ); final articleImages = useMemoized( @@ -176,7 +177,7 @@ class FeedArticleScreen extends HookConsumerWidget { .addTab(url: articleLink.uri); if (context.mounted) { - context.go(BrowserRoute().location); + BrowserRoute().go(context); } }, icon: const Icon(Icons.open_in_browser), @@ -218,7 +219,7 @@ class FeedArticleScreen extends HookConsumerWidget { context, tabName: title.whenNotEmpty, onShow: () { - context.go(BrowserRoute().location); + BrowserRoute().go(context); }, ); } diff --git a/app/lib/features/web_feed/presentation/screens/feed_article_list.dart b/app/lib/features/web_feed/presentation/screens/feed_article_list.dart index 34df0a32..6cc3faef 100644 --- a/app/lib/features/web_feed/presentation/screens/feed_article_list.dart +++ b/app/lib/features/web_feed/presentation/screens/feed_article_list.dart @@ -1,14 +1,15 @@ -import 'package:fast_equatable/fast_equatable.dart'; +import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/extensions/nullable.dart'; -import 'package:lensai/features/web_feed/data/models/feed_filter.dart'; import 'package:lensai/features/web_feed/domain/providers.dart'; import 'package:lensai/features/web_feed/domain/providers/article_filter.dart'; import 'package:lensai/features/web_feed/presentation/controllers/fetch_articles.dart'; import 'package:lensai/features/web_feed/presentation/widgets/feed_article_card.dart'; +import 'package:lensai/presentation/hooks/listenable_callback.dart'; import 'package:lensai/presentation/widgets/failure_widget.dart'; +import 'package:lensai/presentation/widgets/speech_to_text_button.dart'; class FeedArticleListScreen extends HookConsumerWidget { final Uri? feedId; @@ -17,82 +18,170 @@ class FeedArticleListScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final articlesAsync = ref.watch( - // ignore: provider_parameters - feedArticleListProvider(FeedFilter(feedId: feedId)), - ); - return Scaffold( body: NestedScrollView( floatHeaderSlivers: true, headerSliverBuilder: (context, innerBoxIsScrolled) { return [ - Consumer( + HookConsumer( builder: (context, ref, child) { final tags = ref.watch(articleFilterProvider); + final feedTitle = ref.watch( + feedDataProvider( + feedId, + ).select((value) => value.valueOrNull?.title.whenNotEmpty), + ); - return SliverAppBar(floating: true, title: Text('Articles')); + final searchTextController = useTextEditingController(); + + final hasText = useListenableSelector( + searchTextController, + () => searchTextController.text.isNotEmpty, + ); + + useListenableCallback(searchTextController, () { + ref + .read(filteredArticleListProvider(feedId).notifier) + .search(searchTextController.text); + }); + + final bottomHeight = useMemoized(() { + var height = 56.0 + 4.0; + + if (tags.isNotEmpty) { + height += 48; + } + + return height; + }, [tags.isNotEmpty]); + + return SliverAppBar( + floating: true, + title: Text(feedTitle ?? 'Articles'), + bottom: PreferredSize( + preferredSize: Size(double.infinity, bottomHeight), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + TextField( + controller: searchTextController, + decoration: InputDecoration( + label: const Text('Search'), + suffixIcon: + hasText + ? IconButton( + onPressed: () { + searchTextController.clear(); + }, + icon: const Icon(Icons.clear), + ) + : SpeechToTextButton( + onTextReceived: (data) { + searchTextController.text = + data.toString(); + }, + ), + ), + ), + const SizedBox(height: 4), + if (tags.isNotEmpty) + SizedBox( + width: double.infinity, + height: 48, + child: FadingScroll( + fadingSize: 15, + builder: (context, controller) { + return ListView( + controller: controller, + shrinkWrap: true, + scrollDirection: Axis.horizontal, + children: + tags + .map( + (tag) => Padding( + padding: const EdgeInsets.only( + right: 8.0, + ), + child: FilterChip( + label: Text(tag), + showCheckmark: false, + selected: true, + onSelected: (value) {}, + onDeleted: () { + ref + .read( + articleFilterProvider + .notifier, + ) + .removeTag(tag); + }, + ), + ), + ) + .toList(), + ); + }, + ), + ), + ], + ), + ), + ), + ); }, ), ]; }, - body: articlesAsync.when( - data: (articles) { - return RefreshIndicator( - onRefresh: () async { - if (feedId != null) { - await ref - .read(fetchArticlesControllerProvider.notifier) - .fetchFeedArticles(feedId!); - } else { - await ref - .read(fetchArticlesControllerProvider.notifier) - .fetchAllArticles(); - } + body: Consumer( + builder: (context, ref, child) { + final articlesAsync = ref.watch( + // ignore: provider_parameters + filteredArticleListProvider(feedId), + ); + + return articlesAsync.when( + skipLoadingOnReload: true, + data: (articles) { + return RefreshIndicator( + onRefresh: () async { + if (feedId != null) { + await ref + .read(fetchArticlesControllerProvider.notifier) + .fetchFeedArticles(feedId!); + } else { + await ref + .read(fetchArticlesControllerProvider.notifier) + .fetchAllArticles(); + } + }, + child: MediaQuery.removePadding( + removeTop: true, + context: context, + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: articles.length, + itemBuilder: (context, i) { + final article = articles[i]; + return FeedArticleCard( + key: ValueKey(article.id), + article: article, + ); + }, + ), + ), + ); }, - child: ListView.builder( - itemCount: articles.length, - itemBuilder: (context, i) { - final article = articles[i]; - - return Consumer( - key: ValueKey(article.id), - builder: (context, ref, child) { - final tags = ref.watch( - articleFilterProvider.select( - (value) => EquatableValue(value.tags ?? const {}), - ), - ); - - return FeedArticleCard( - selectedTags: tags.value, - onTagSelected: (tagId, value) { - if (value) { - ref - .read(articleFilterProvider.notifier) - .addTag(tagId); - } else { - ref - .read(articleFilterProvider.notifier) - .removeTag(tagId); - } - }, - article: article, - ); - }, - ); - }, - ), + error: + (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load Articles', + exception: error, + ), + ), + loading: () => const SizedBox.shrink(), ); }, - error: - (error, stackTrace) => Center( - child: FailureWidget( - title: 'Failed to load Articles', - exception: error, - ), - ), - loading: () => const SizedBox.shrink(), ), ), ); diff --git a/app/lib/features/web_feed/presentation/screens/feed_edit.dart b/app/lib/features/web_feed/presentation/screens/feed_edit.dart index e9fb897c..728f3364 100644 --- a/app/lib/features/web_feed/presentation/screens/feed_edit.dart +++ b/app/lib/features/web_feed/presentation/screens/feed_edit.dart @@ -1,33 +1,109 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/extensions/uri.dart'; import 'package:lensai/features/web_feed/data/database/database.dart'; import 'package:lensai/features/web_feed/data/models/feed_category.dart'; +import 'package:lensai/features/web_feed/domain/providers.dart'; import 'package:lensai/features/web_feed/domain/repositories/feed_repository.dart'; import 'package:lensai/features/web_feed/presentation/widgets/tag_field.dart'; +import 'package:lensai/presentation/widgets/failure_widget.dart'; import 'package:lensai/presentation/widgets/url_icon.dart'; +import 'package:lensai/utils/form_validators.dart'; +import 'package:lensai/utils/uri_parser.dart' as uri_parser; enum _DialogMode { create, edit } class FeedEditScreen extends HookConsumerWidget { final _DialogMode _mode; - final FeedData initialFeed; + final Uri feedId; - const FeedEditScreen._({required _DialogMode mode, required this.initialFeed}) + const FeedEditScreen._({required _DialogMode mode, required this.feedId}) : _mode = mode; - factory FeedEditScreen.create({required FeedData initialFeed}) { - return FeedEditScreen._(mode: _DialogMode.create, initialFeed: initialFeed); + factory FeedEditScreen.create({required Uri feedId}) { + return FeedEditScreen._(mode: _DialogMode.create, feedId: feedId); } - factory FeedEditScreen.edit({required FeedData initialFeed}) { - return FeedEditScreen._(mode: _DialogMode.edit, initialFeed: initialFeed); + factory FeedEditScreen.edit({required Uri feedId}) { + return FeedEditScreen._(mode: _DialogMode.edit, feedId: feedId); } + @override + Widget build(BuildContext context, WidgetRef ref) { + final initialFeedAsync = switch (_mode) { + _DialogMode.create => ref.watch( + fetchWebFeedProvider( + feedId, + ).select((value) => value.whenData((result) => result.feedData)), + ), + _DialogMode.edit => ref.watch(feedDataProvider(feedId)), + }; + + return initialFeedAsync.when( + skipLoadingOnReload: true, + data: (initialFeed) { + if (initialFeed == null) { + return Scaffold( + key: const ValueKey('data'), + appBar: AppBar(), + body: const Center( + child: FailureWidget(title: 'Failed to load feed'), + ), + ); + } + + return _FeedEditContent(mode: _mode, initialFeed: initialFeed); + }, + error: + (error, stackTrace) => Scaffold( + key: const ValueKey('error'), + appBar: AppBar(), + body: Center( + child: FailureWidget( + title: 'Failed to load feed', + exception: error, + ), + ), + ), + loading: + () => Scaffold( + key: const ValueKey('loading'), + appBar: AppBar( + title: Text(switch (_mode) { + _DialogMode.create => 'New Feed', + _DialogMode.edit => 'Edit Feed', + }), + ), + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + Padding( + padding: EdgeInsets.only(top: 8.0), + child: Text('Fetching feed...'), + ), + ], + ), + ), + ), + ); + } +} + +class _FeedEditContent extends HookConsumerWidget { + final _DialogMode _mode; + + final FeedData initialFeed; + + const _FeedEditContent({required _DialogMode mode, required this.initialFeed}) + : _mode = mode; + @override Widget build(BuildContext context, WidgetRef ref) { final formKey = useMemoized(() => GlobalKey()); @@ -45,6 +121,12 @@ class FeedEditScreen extends HookConsumerWidget { final urlTextController = useTextEditingController( text: initialFeed.url.toString(), ); + final iconUrlTextController = useTextEditingController( + text: initialFeed.icon?.toString(), + ); + final siteLinkTextController = useTextEditingController( + text: initialFeed.siteLink?.toString(), + ); return Scaffold( appBar: AppBar( @@ -57,9 +139,21 @@ class FeedEditScreen extends HookConsumerWidget { onPressed: () async { if (formKey.currentState?.validate() ?? false) { final feedData = FeedData( - url: Uri.parse(urlTextController.text), + url: + uri_parser.tryParseUrl( + urlTextController.text, + eagerParsing: true, + )!, authors: initialFeed.authors, description: descriptionTextController.text.whenNotEmpty, + icon: uri_parser.tryParseUrl( + iconUrlTextController.text, + eagerParsing: true, + ), + siteLink: uri_parser.tryParseUrl( + siteLinkTextController.text, + eagerParsing: true, + ), tags: tags.value.map((tag) => FeedCategory(id: tag)).toList(), title: titleTextController.text.whenNotEmpty, ); @@ -91,10 +185,11 @@ class FeedEditScreen extends HookConsumerWidget { decoration: InputDecoration( prefixIcon: Padding( padding: const EdgeInsets.all(10.0), - child: UrlIcon( - initialFeed.url.base, - iconSize: 24.0, - ), + child: UrlIcon([ + initialFeed.icon ?? + initialFeed.siteLink ?? + initialFeed.url.base, + ], iconSize: 24.0), ), label: const Text('Title'), ), @@ -103,40 +198,63 @@ class FeedEditScreen extends HookConsumerWidget { TextFormField( decoration: const InputDecoration( label: Text('Description'), + prefixIcon: Icon(Icons.short_text), ), minLines: 1, maxLines: 3, controller: descriptionTextController, ), - const SizedBox(height: 16), + const SizedBox(height: 32), + TextFormField( + decoration: const InputDecoration( + label: Text('Icon URL'), + prefixIcon: Icon(Icons.image), + ), + keyboardType: TextInputType.url, + controller: iconUrlTextController, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + return validateUrl( + value, + onlyHttpProtocol: true, + required: false, + ); + }, + ), + TextFormField( + decoration: const InputDecoration( + label: Text('Site Link'), + prefixIcon: Icon(Icons.link), + ), + keyboardType: TextInputType.url, + controller: siteLinkTextController, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + return validateUrl( + value, + onlyHttpProtocol: true, + required: false, + ); + }, + ), + const SizedBox(height: 32), TagField( initialTags: tags.value, onTagsUpdate: (newTags) { tags.value = newTags; }, ), - const SizedBox(height: 16), + const SizedBox(height: 32), TextFormField( decoration: const InputDecoration( - label: Text('Address'), + label: Text('Feed URL'), + prefixIcon: Icon(MdiIcons.rss), ), keyboardType: TextInputType.url, controller: urlTextController, autovalidateMode: AutovalidateMode.onUserInteraction, validator: (value) { - if (value.isEmpty) { - return 'Address must be provided'; - } - - if (Uri.tryParse(value!) case final Uri url) { - if (url.isScheme('https') || - url.isScheme('http') && - url.authority.isNotEmpty) { - return null; - } - } - - return 'Inavlid URL'; + return validateUrl(value, onlyHttpProtocol: true); }, ), ], diff --git a/app/lib/features/web_feed/presentation/screens/feed_list.dart b/app/lib/features/web_feed/presentation/screens/feed_list.dart index d995ec0d..7cd19d50 100644 --- a/app/lib/features/web_feed/presentation/screens/feed_list.dart +++ b/app/lib/features/web_feed/presentation/screens/feed_list.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/features/web_feed/domain/providers.dart'; import 'package:lensai/features/web_feed/presentation/controllers/fetch_articles.dart'; import 'package:lensai/features/web_feed/presentation/widgets/feed_card.dart'; @@ -15,6 +16,7 @@ class FeedListScreen extends HookConsumerWidget { return Scaffold( appBar: AppBar(title: const Text('Feeds')), body: feeds.when( + skipLoadingOnReload: true, data: (feeds) { return RefreshIndicator( onRefresh: () async { @@ -35,10 +37,21 @@ class FeedListScreen extends HookConsumerWidget { child: FailureWidget( title: 'Failed to load Feeds', exception: error, + onRetry: () { + // ignore: unused_result + ref.refresh(feedListProvider); + }, ), ), loading: () => const SizedBox.shrink(), ), + floatingActionButton: FloatingActionButton.extended( + label: const Text('Feed'), + icon: const Icon(Icons.add), + onPressed: () async { + await const FeedAddRoute().push(context); + }, + ), ); } } diff --git a/app/lib/features/web_feed/presentation/select_feed_dialog.dart b/app/lib/features/web_feed/presentation/select_feed_dialog.dart new file mode 100644 index 00000000..29735e8f --- /dev/null +++ b/app/lib/features/web_feed/presentation/select_feed_dialog.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/core/routing/routes.dart'; +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/features/web_feed/domain/providers.dart'; +import 'package:lensai/presentation/widgets/failure_widget.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +class SelectFeedDialog extends HookConsumerWidget { + final Set feedUris; + + const SelectFeedDialog({required this.feedUris}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SimpleDialog( + title: const Text('Add Feed'), + children: + feedUris + .map( + (uri) => HookConsumer( + builder: (context, ref, child) { + final feedAsync = ref.watch(fetchWebFeedProvider(uri)); + + return feedAsync.when( + data: (data) { + return ListTile( + title: Text( + data.feedData.title.whenNotEmpty ?? 'Unnamed Feed', + ), + subtitle: Text(uri.toString()), + trailing: const Icon(Icons.add), + onTap: () { + FeedCreateRoute( + feedId: uri, + ).pushReplacement(context); + }, + ); + }, + error: + (error, stackTrace) => FailureWidget( + title: 'Failed to fetch Feed', + exception: error, + onRetry: () { + // ignore: unused_result + ref.refresh(fetchWebFeedProvider(uri)); + }, + ), + loading: + () => Skeletonizer( + child: ListTile( + title: Text(BoneMock.title), + subtitle: Skeleton.keep( + child: Text(uri.toString()), + ), + ), + ), + ); + }, + ), + ) + .toList(), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/widgets/authors_horizontal_list.dart b/app/lib/features/web_feed/presentation/widgets/authors_horizontal_list.dart index 5dcf9d3a..e628b0c1 100644 --- a/app/lib/features/web_feed/presentation/widgets/authors_horizontal_list.dart +++ b/app/lib/features/web_feed/presentation/widgets/authors_horizontal_list.dart @@ -6,18 +6,31 @@ import 'package:lensai/features/web_feed/data/models/feed_author.dart'; class AuthorsHorizontalList extends StatelessWidget { late final List _authors; - AuthorsHorizontalList({required List authors}) { + AuthorsHorizontalList({ + required List authors, + Set selectedTags = const {}, + void Function(String tagId, bool value)? onTagSelected, + }) { _authors = - authors - .map( - (author) => Chip( - label: Text( - '${author.name ?? ''} ${author.email.mapNotNull((email) => '($email)') ?? ''}' - .trim(), + authors.map((author) { + final label = Text( + '${author.name ?? ''} ${author.email.mapNotNull((email) => '($email)') ?? ''}' + .trim(), + ); + + return onTagSelected.mapNotNull( + (onTagSelected) => FilterChip( + label: label, + selected: selectedTags.contains(author.name), + onSelected: (value) { + if (author.name.isNotEmpty) { + onTagSelected(author.name!, value); + } + }, ), - ), - ) - .toList(); + ) ?? + Chip(label: label); + }).toList(); } @override @@ -30,7 +43,6 @@ class AuthorsHorizontalList extends StatelessWidget { return ListView.builder( itemCount: _authors.length, controller: controller, - shrinkWrap: true, scrollDirection: Axis.horizontal, itemBuilder: (context, index) => _authors[index], ); diff --git a/app/lib/features/web_feed/presentation/widgets/feed_article_card.dart b/app/lib/features/web_feed/presentation/widgets/feed_article_card.dart index d134925b..fe5a331a 100644 --- a/app/lib/features/web_feed/presentation/widgets/feed_article_card.dart +++ b/app/lib/features/web_feed/presentation/widgets/feed_article_card.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/extensions/uri.dart'; import 'package:lensai/features/web_feed/data/models/feed_article.dart'; +import 'package:lensai/features/web_feed/data/models/feed_article_query_result.dart'; +import 'package:lensai/features/web_feed/domain/providers/article_filter.dart'; import 'package:lensai/features/web_feed/domain/repositories/feed_repository.dart'; import 'package:lensai/features/web_feed/extensions/feed_article.dart'; import 'package:lensai/features/web_feed/presentation/widgets/authors_horizontal_list.dart'; @@ -15,34 +17,31 @@ import 'package:timeago/timeago.dart' as timeago; class FeedArticleCard extends HookConsumerWidget { final FeedArticle article; - final Set selectedTags; - final void Function(String tagId, bool value)? onTagSelected; - - const FeedArticleCard({ - super.key, - required this.article, - this.onTagSelected, - this.selectedTags = const {}, - }); + const FeedArticleCard({super.key, required this.article}); @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); + final tags = ref.watch(articleFilterProvider); + + final titleHighlight = switch (article) { + final FeedArticleQueryResult result => result.titleHighlight.whenNotEmpty, + _ => null, + }; + + final searchSnippet = switch (article) { + final FeedArticleQueryResult result => + result.summarySnippet.whenNotEmpty ?? + result.contentSnippet.whenNotEmpty, + _ => null, + }; + return Card( clipBehavior: Clip.antiAlias, child: InkWell( onTap: () async { - await ref - .read(feedRepositoryProvider.notifier) - .touchArticleRead(article.id); - - if (context.mounted) { - await context.push( - FeedArticleRoute(articleId: article.id).location, - extra: article, - ); - } + await FeedArticleRoute(articleId: article.id).push(context); }, child: Padding( padding: const EdgeInsets.all(16.0), @@ -52,17 +51,55 @@ class FeedArticleCard extends HookConsumerWidget { children: [ Row( children: [ - UrlIcon(article.feedId.base, iconSize: 34.0), + UrlIcon([ + article.icon ?? article.feedId.base, + ], iconSize: 34.0), const SizedBox(width: 12.0), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - article.displayTitle, - style: theme.textTheme.titleMedium, - ), - if (article.summaryPlain != null) + if (titleHighlight.isNotEmpty) + MarkdownBody( + data: titleHighlight!, + styleSheet: MarkdownStyleSheet( + p: Theme.of( + context, + ).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + if (titleHighlight.isEmpty) + Text( + article.displayTitle, + style: theme.textTheme.titleMedium, + ), + if (searchSnippet.isNotEmpty) + MarkdownBody( + data: searchSnippet!, + styleSheet: MarkdownStyleSheet( + p: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith( + color: + Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + a: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith( + color: + Theme.of( + context, + ).colorScheme.onSurfaceVariant, + decoration: TextDecoration.none, + ), + ), + ), + if (searchSnippet.isEmpty && + article.summaryPlain != null) Text( article.summaryPlain!, style: theme.textTheme.bodySmall, @@ -86,15 +123,35 @@ class FeedArticleCard extends HookConsumerWidget { if (article.authors.isNotEmpty || article.tags.isNotEmpty) ...[ const SizedBox(height: 8), if (article.authors.isNotEmpty) - AuthorsHorizontalList(authors: article.authors!), + AuthorsHorizontalList( + authors: article.authors!, + selectedTags: tags, + onTagSelected: (tagId, value) { + if (value) { + ref.read(articleFilterProvider.notifier).addTag(tagId); + } else { + ref + .read(articleFilterProvider.notifier) + .removeTag(tagId); + } + }, + ), if (article.tags.isNotEmpty) TagsHorizontalList( tags: article.tags!, - selectedTags: selectedTags, - onTagSelected: onTagSelected, + selectedTags: tags, + onTagSelected: (tagId, value) { + if (value) { + ref.read(articleFilterProvider.notifier).addTag(tagId); + } else { + ref + .read(articleFilterProvider.notifier) + .removeTag(tagId); + } + }, ), - const Divider(), ], + const Divider(), Row( children: [ Text( diff --git a/app/lib/features/web_feed/presentation/widgets/feed_card.dart b/app/lib/features/web_feed/presentation/widgets/feed_card.dart index f25e9765..4e1d5daa 100644 --- a/app/lib/features/web_feed/presentation/widgets/feed_card.dart +++ b/app/lib/features/web_feed/presentation/widgets/feed_card.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; 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/extensions/nullable.dart'; @@ -26,7 +25,7 @@ class FeedCard extends HookConsumerWidget { clipBehavior: Clip.antiAlias, child: InkWell( onTap: () async { - await context.push(FeedArticleListRoute(feedId: feed.url).location); + await FeedArticleListRoute(feedId: feed.url).push(context); }, child: Padding( padding: const EdgeInsets.all(16.0), @@ -36,7 +35,9 @@ class FeedCard extends HookConsumerWidget { children: [ Row( children: [ - UrlIcon(feed.url.base, iconSize: 34.0), + UrlIcon([ + feed.icon ?? feed.siteLink ?? feed.url.base, + ], iconSize: 34.0), const SizedBox(width: 12.0), Expanded( child: Column( @@ -56,6 +57,12 @@ class FeedCard extends HookConsumerWidget { ], ), ), + IconButton( + onPressed: () async { + await FeedEditRoute(feedId: feed.url).push(context); + }, + icon: const Icon(Icons.edit), + ), ], ), if (feed.authors.isNotEmpty || feed.tags.isNotEmpty) ...[ @@ -89,6 +96,7 @@ class FeedCard extends HookConsumerWidget { ); return countAsync.when( + skipLoadingOnReload: true, data: (count) { if (count == null) { return const SizedBox(); diff --git a/app/lib/features/web_feed/presentation/widgets/tag_field.dart b/app/lib/features/web_feed/presentation/widgets/tag_field.dart index e4d03f2a..d5180787 100644 --- a/app/lib/features/web_feed/presentation/widgets/tag_field.dart +++ b/app/lib/features/web_feed/presentation/widgets/tag_field.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:lensai/presentation/hooks/listenable_callback.dart'; final _tagSplitPatter = RegExp(r'[,\s]+'); @@ -45,9 +46,8 @@ class TagField extends HookWidget { TextField( controller: textController, decoration: const InputDecoration( - label: Text('Add'), - floatingLabelBehavior: FloatingLabelBehavior.always, hintText: 'tag1, tag2, ...', + prefixIcon: Icon(MdiIcons.tagMultiple), ), onChanged: (String value) { if (value.isNotEmpty) { diff --git a/app/lib/features/web_feed/presentation/widgets/tags_horizontal_list.dart b/app/lib/features/web_feed/presentation/widgets/tags_horizontal_list.dart index fe449795..9424195d 100644 --- a/app/lib/features/web_feed/presentation/widgets/tags_horizontal_list.dart +++ b/app/lib/features/web_feed/presentation/widgets/tags_horizontal_list.dart @@ -12,25 +12,27 @@ class TagsHorizontalList extends StatelessWidget { void Function(String tagId, bool value)? onTagSelected, }) { _tags = - tags - .map( - (tag) => Padding( - padding: const EdgeInsets.only(right: 8.0), - child: FilterChip( - label: Text( - '${tag.id} ${tag.title.mapNotNull((title) => '($title)') ?? ''}' - .trim(), - ), - selected: selectedTags.contains(tag.id), - onSelected: onTagSelected.mapNotNull( - (onTagSelected) => (value) { + tags.map((tag) { + final label = Text( + '${tag.id} ${tag.title.mapNotNull((title) => '($title)') ?? ''}' + .trim(), + ); + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: + onTagSelected.mapNotNull( + (onTagSelected) => FilterChip( + label: label, + selected: selectedTags.contains(tag.id), + onSelected: (value) { onTagSelected(tag.id, value); }, ), - ), - ), - ) - .toList(); + ) ?? + Chip(label: label), + ); + }).toList(); } @override @@ -43,7 +45,8 @@ class TagsHorizontalList extends StatelessWidget { return ListView.builder( itemCount: _tags.length, controller: controller, - shrinkWrap: true, + //Improve list performance by not rendering outside screen at all + cacheExtent: 0, scrollDirection: Axis.horizontal, itemBuilder: (context, index) => _tags[index], ); diff --git a/app/lib/features/web_feed/utils/feed_finder.dart b/app/lib/features/web_feed/utils/feed_finder.dart index 353d95ee..788e1ae9 100644 --- a/app/lib/features/web_feed/utils/feed_finder.dart +++ b/app/lib/features/web_feed/utils/feed_finder.dart @@ -32,7 +32,7 @@ class FeedFinder { // return results; // } - void _parseBody(Set candidates) { + void _parseBody(Set candidates) { for (final a in document.querySelectorAll('a')) { var href = a.attributes['href']; if (href != null) { @@ -46,13 +46,15 @@ class FeedFinder { // Fix naked URLs href = !href.startsWith('http') ? '$_base/$href' : href; - candidates.add(href); + if (Uri.tryParse(href) case final Uri uri) { + candidates.add(uri); + } } } } } - void _parseHead(Set candidates) { + void _parseHead(Set candidates) { for (final link in document.querySelectorAll("link[rel='alternate']")) { final type = link.attributes['type']; if (type != null) { @@ -61,19 +63,22 @@ class FeedFinder { if (href != null) { // Fix relative URLs href = href.startsWith('/') ? _base + href : href; - candidates.add(href); + + if (Uri.tryParse(href) case final Uri uri) { + candidates.add(uri); + } } } } } } - Future> parse({ + Future> parse({ bool parseHead = true, bool parseBody = true, // bool verifyCandidates = true, }) async { - final candidates = {}; + final candidates = {}; // Look for feed candidates in head if (parseHead) { diff --git a/app/lib/features/web_feed/utils/feed_parser.dart b/app/lib/features/web_feed/utils/feed_parser.dart index fc0875e1..0b29a8f6 100644 --- a/app/lib/features/web_feed/utils/feed_parser.dart +++ b/app/lib/features/web_feed/utils/feed_parser.dart @@ -38,6 +38,7 @@ class FeedParser { url: url, title: feed.title.whenNotEmpty ?? feed.dc?.title, description: feed.description.whenNotEmpty ?? feed.dc?.description, + siteLink: feed.link.mapNotNull(Uri.tryParse), authors: feed.dc?.creator.whenNotEmpty.mapNotNull( (creator) => [FeedAuthor(name: creator)], ), @@ -50,6 +51,7 @@ class FeedParser { url: url, title: feed.title.whenNotEmpty ?? feed.dc?.title, description: feed.description.whenNotEmpty ?? feed.dc?.description, + siteLink: feed.link.mapNotNull(Uri.tryParse), authors: (feed.author.whenNotEmpty ?? feed.dc?.creator.whenNotEmpty) .mapNotNull((creator) => [FeedAuthor(name: creator)]), tags: @@ -62,6 +64,12 @@ class FeedParser { return FeedData( url: url, title: feed.title.whenNotEmpty, + icon: feed.icon.mapNotNull(Uri.tryParse), + siteLink: + feed.links + .toFeedLinks() + .getRelation(FeedLinkRelation.alternate) + ?.uri, description: feed.subtitle.whenNotEmpty, authors: authors.isNotEmpty ? authors : null, tags: tags, @@ -76,18 +84,22 @@ class FeedParser { switch (_feed) { case final Rss1Feed feed: - final processedContents = await GeckoTurndownService().turndownHtml( - feed.items.map((item) => item.content?.value ?? '').toList(), - ); + final processedContents = + await GeckoBrowserExtensionService.turndownHtml( + feed.items.map((item) => item.content?.value ?? '').toList(), + ); - final processedSummaries = await GeckoTurndownService().turndownHtml( - feed.items - .map( - (item) => - item.description.whenNotEmpty ?? item.dc?.description ?? '', - ) - .toList(), - ); + final processedSummaries = + await GeckoBrowserExtensionService.turndownHtml( + feed.items + .map( + (item) => + item.description.whenNotEmpty ?? + item.dc?.description ?? + '', + ) + .toList(), + ); return feed.items.mapIndexed((i, item) { final title = item.title.whenNotEmpty ?? item.dc?.title.whenNotEmpty; @@ -116,18 +128,22 @@ class FeedParser { ); }).toList(); case final RssFeed feed: - final processedContents = await GeckoTurndownService().turndownHtml( - feed.items.map((item) => item.content?.value ?? '').toList(), - ); + final processedContents = + await GeckoBrowserExtensionService.turndownHtml( + feed.items.map((item) => item.content?.value ?? '').toList(), + ); - final processedSummaries = await GeckoTurndownService().turndownHtml( - feed.items - .map( - (item) => - item.description.whenNotEmpty ?? item.dc?.description ?? '', - ) - .toList(), - ); + final processedSummaries = + await GeckoBrowserExtensionService.turndownHtml( + feed.items + .map( + (item) => + item.description.whenNotEmpty ?? + item.dc?.description ?? + '', + ) + .toList(), + ); return feed.items.mapIndexed((i, item) { final title = item.title.whenNotEmpty ?? item.dc?.title.whenNotEmpty; @@ -169,16 +185,18 @@ class FeedParser { ); }).toList(); case final AtomFeed feed: - final processedContents = await GeckoTurndownService().turndownHtml( - feed.items.map((item) => item.content ?? '').toList(), - ); + final processedContents = + await GeckoBrowserExtensionService.turndownHtml( + feed.items.map((item) => item.content ?? '').toList(), + ); - final processedSummaries = await GeckoTurndownService().turndownHtml( - feed.items.map((item) => item.summary ?? '').toList(), - ); + final processedSummaries = + await GeckoBrowserExtensionService.turndownHtml( + feed.items.map((item) => item.summary ?? '').toList(), + ); - final feedLink = feed.links.toFeedLinks().firstWhereOrNull( - (link) => link.relation == FeedLinkRelation.self, + final feedLink = feed.links.toFeedLinks().getRelation( + FeedLinkRelation.self, ); return feed.items.mapIndexed((i, item) { @@ -187,9 +205,7 @@ class FeedParser { final tags = item.categories.toFeedCategories(); final itemLinks = item.links.toFeedLinks(); - final articleLink = itemLinks.firstWhereOrNull( - (link) => link.relation == FeedLinkRelation.alternate, - ); + final articleLink = itemLinks.getRelation(FeedLinkRelation.alternate); final itemId = item.id.whenNotEmpty ?? item.title; final uniqueId = diff --git a/app/lib/presentation/controllers/website_title.dart b/app/lib/presentation/controllers/website_title.dart index 3a58511c..ad61d6f0 100644 --- a/app/lib/presentation/controllers/website_title.dart +++ b/app/lib/presentation/controllers/website_title.dart @@ -6,10 +6,14 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'website_title.g.dart'; @Riverpod() -Future pageInfo(Ref ref, Uri url) async { +Future pageInfo( + Ref ref, + Uri url, { + required bool isImageRequest, +}) async { final websiteService = ref.watch(genericWebsiteServiceProvider.notifier); - final result = await websiteService.fetchPageInfo(url); + final result = await websiteService.fetchPageInfo(url, isImageRequest); if (result.isSuccess) { ref.keepAlive(); diff --git a/app/lib/presentation/controllers/website_title.g.dart b/app/lib/presentation/controllers/website_title.g.dart index 18b6af6a..4b2d8faa 100644 --- a/app/lib/presentation/controllers/website_title.g.dart +++ b/app/lib/presentation/controllers/website_title.g.dart @@ -6,7 +6,7 @@ part of 'website_title.dart'; // RiverpodGenerator // ************************************************************************** -String _$pageInfoHash() => r'bdb860ec904959d7aa561045b85b75b209fb38e1'; +String _$pageInfoHash() => r'dd5644057e4ba7280275105de4788eb4046592d4'; /// Copied from Dart SDK class _SystemHash { @@ -39,13 +39,13 @@ class PageInfoFamily extends Family> { const PageInfoFamily(); /// See also [pageInfo]. - PageInfoProvider call(Uri url) { - return PageInfoProvider(url); + PageInfoProvider call(Uri url, {required bool isImageRequest}) { + return PageInfoProvider(url, isImageRequest: isImageRequest); } @override PageInfoProvider getProviderOverride(covariant PageInfoProvider provider) { - return call(provider.url); + return call(provider.url, isImageRequest: provider.isImageRequest); } static const Iterable? _dependencies = null; @@ -66,9 +66,10 @@ class PageInfoFamily extends Family> { /// See also [pageInfo]. class PageInfoProvider extends AutoDisposeFutureProvider { /// See also [pageInfo]. - PageInfoProvider(Uri url) + PageInfoProvider(Uri url, {required bool isImageRequest}) : this._internal( - (ref) => pageInfo(ref as PageInfoRef, url), + (ref) => + pageInfo(ref as PageInfoRef, url, isImageRequest: isImageRequest), from: pageInfoProvider, name: r'pageInfoProvider', debugGetCreateSourceHash: @@ -78,6 +79,7 @@ class PageInfoProvider extends AutoDisposeFutureProvider { dependencies: PageInfoFamily._dependencies, allTransitiveDependencies: PageInfoFamily._allTransitiveDependencies, url: url, + isImageRequest: isImageRequest, ); PageInfoProvider._internal( @@ -88,9 +90,11 @@ class PageInfoProvider extends AutoDisposeFutureProvider { required super.debugGetCreateSourceHash, required super.from, required this.url, + required this.isImageRequest, }) : super.internal(); final Uri url; + final bool isImageRequest; @override Override overrideWith( @@ -106,6 +110,7 @@ class PageInfoProvider extends AutoDisposeFutureProvider { allTransitiveDependencies: null, debugGetCreateSourceHash: null, url: url, + isImageRequest: isImageRequest, ), ); } @@ -117,13 +122,16 @@ class PageInfoProvider extends AutoDisposeFutureProvider { @override bool operator ==(Object other) { - return other is PageInfoProvider && other.url == url; + return other is PageInfoProvider && + other.url == url && + other.isImageRequest == isImageRequest; } @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); hash = _SystemHash.combine(hash, url.hashCode); + hash = _SystemHash.combine(hash, isImageRequest.hashCode); return _SystemHash.finish(hash); } @@ -134,6 +142,9 @@ class PageInfoProvider extends AutoDisposeFutureProvider { mixin PageInfoRef on AutoDisposeFutureProviderRef { /// The parameter `url` of this provider. Uri get url; + + /// The parameter `isImageRequest` of this provider. + bool get isImageRequest; } class _PageInfoProviderElement @@ -143,6 +154,8 @@ class _PageInfoProviderElement @override Uri get url => (origin as PageInfoProvider).url; + @override + bool get isImageRequest => (origin as PageInfoProvider).isImageRequest; } // ignore_for_file: type=lint diff --git a/app/lib/presentation/widgets/selectable_chips.dart b/app/lib/presentation/widgets/selectable_chips.dart index fbe86619..9806af2d 100644 --- a/app/lib/presentation/widgets/selectable_chips.dart +++ b/app/lib/presentation/widgets/selectable_chips.dart @@ -64,7 +64,6 @@ class SelectableChips extends StatelessWidget { return ListView.builder( controller: controller, scrollDirection: Axis.horizontal, - shrinkWrap: true, itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; diff --git a/app/lib/presentation/widgets/url_icon.dart b/app/lib/presentation/widgets/url_icon.dart index 874eb612..56da2fb6 100644 --- a/app/lib/presentation/widgets/url_icon.dart +++ b/app/lib/presentation/widgets/url_icon.dart @@ -7,17 +7,17 @@ import 'package:skeletonizer/skeletonizer.dart'; class UrlIcon extends HookConsumerWidget { final double iconSize; - final Uri url; + final List urlList; - const UrlIcon(this.url, {required this.iconSize, super.key}); + const UrlIcon(this.urlList, {required this.iconSize, super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final icon = useCachedFuture( () => // ignore: discarded_futures - ref.read(genericWebsiteServiceProvider.notifier).getUrlIcon(url), - [url], + ref.read(genericWebsiteServiceProvider.notifier).getUrlIcon(urlList), + [urlList], ); return Skeletonizer( diff --git a/app/lib/presentation/widgets/website_feed_tile.dart b/app/lib/presentation/widgets/website_feed_tile.dart index 2729d9df..cc584aca 100644 --- a/app/lib/presentation/widgets/website_feed_tile.dart +++ b/app/lib/presentation/widgets/website_feed_tile.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/data/models/web_page_info.dart'; import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/presentation/controllers/website_title.dart'; @@ -17,11 +20,12 @@ class WebsiteFeedTile extends HookConsumerWidget { final pageInfoAsync = (precachedInfo?.feeds != null) ? AsyncValue.data(precachedInfo!) - : ref.watch(pageInfoProvider(url)); + : ref.watch(pageInfoProvider(url, isImageRequest: false)); return Skeletonizer( enabled: pageInfoAsync.isLoading && precachedInfo?.feeds == null, child: pageInfoAsync.when( + skipLoadingOnReload: true, data: (info) { if (info.feeds.isEmpty) { return const SizedBox.shrink(); @@ -39,6 +43,13 @@ class WebsiteFeedTile extends HookConsumerWidget { ), ), ), + onTap: () async { + await SelectFeedDialogRoute( + feedsJson: jsonEncode( + info.feeds!.map((feed) => feed.toString()).toList(), + ), + ).push(context); + }, ); }, error: (error, stackTrace) { diff --git a/app/lib/presentation/widgets/website_title_tile.dart b/app/lib/presentation/widgets/website_title_tile.dart index ba4c5505..8101d801 100644 --- a/app/lib/presentation/widgets/website_title_tile.dart +++ b/app/lib/presentation/widgets/website_title_tile.dart @@ -16,11 +16,12 @@ class WebsiteTitleTile extends HookConsumerWidget { final pageInfoAsync = (precachedInfo?.isPageInfoComplete ?? false) ? AsyncValue.data(precachedInfo!) - : ref.watch(pageInfoProvider(url)); + : ref.watch(pageInfoProvider(url, isImageRequest: false)); return Skeletonizer( enabled: pageInfoAsync.isLoading && precachedInfo == null, child: pageInfoAsync.when( + skipLoadingOnReload: true, data: (info) { return ListTile( leading: RawImage( @@ -36,7 +37,8 @@ class WebsiteTitleTile extends HookConsumerWidget { error: (error, stackTrace) { return FailureWidget( title: error.toString(), - onRetry: () => ref.refresh(pageInfoProvider(url)), + onRetry: + () => ref.refresh(pageInfoProvider(url, isImageRequest: false)), ); }, loading: diff --git a/app/lib/utils/form_validators.dart b/app/lib/utils/form_validators.dart new file mode 100644 index 00000000..ddc816a2 --- /dev/null +++ b/app/lib/utils/form_validators.dart @@ -0,0 +1,30 @@ +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/utils/uri_parser.dart' as uri_parser; + +String? validateUrl( + String? value, { + bool requireAuthority = true, + bool eagerParsing = true, + bool onlyHttpProtocol = false, + bool required = true, +}) { + if (value.isEmpty) { + if (required) { + return 'URL must be provided'; + } else { + return null; + } + } + + if (uri_parser.tryParseUrl(value, eagerParsing: eagerParsing) + case final Uri url) { + if (!requireAuthority || url.authority.isNotEmpty) { + if (!onlyHttpProtocol || + (url.isScheme('https') || url.isScheme('http'))) { + return null; + } + } + } + + return 'Inavlid URL'; +} diff --git a/app/lib/utils/lru_cache.dart b/app/lib/utils/lru_cache.dart index 35c93f62..8a52a012 100644 --- a/app/lib/utils/lru_cache.dart +++ b/app/lib/utils/lru_cache.dart @@ -23,6 +23,10 @@ class LRUCache { _capacity = capacity; } + bool contains(K key) { + return _cache.containsKey(key); + } + V? get(K key) { final value = _cache.remove(key); // Temporarily remove the item. diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/feed.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/feed.js new file mode 100644 index 00000000..15cf8d38 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/feed.js @@ -0,0 +1,40 @@ +const FEED_MIME_TYPES = [ + "application/atom", + "application/rss" +]; + +// Listen for any webRequest that might be a feed +browser.webRequest.onHeadersReceived.addListener( + function (details) { + let isFeed = false; + + for (let header of details.responseHeaders) { + if (header.name.toLowerCase() === "content-type") { + const contentType = header.value.toLowerCase(); + + for (const mimeType of FEED_MIME_TYPES) { + if (contentType.includes(mimeType)) { + isFeed = true; + break; + } + } + + if (isFeed) break; + } + } + + if (isFeed) { + port.postMessage({ + "type": "feedRequest", + "url": details.url + }); + + return { cancel: true }; + } + + // Not a feed, let the browser handle it normally + return { responseHeaders: details.responseHeaders }; + }, + { urls: [""] }, + ["blocking", "responseHeaders"] +); \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/turndown/manifest.json b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/manifest.json similarity index 60% rename from packages/flutter_mozilla_components/android/src/main/assets/extensions/turndown/manifest.json rename to packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/manifest.json index 1112ee49..43473623 100644 --- a/packages/flutter_mozilla_components/android/src/main/assets/extensions/turndown/manifest.json +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/manifest.json @@ -2,20 +2,24 @@ "manifest_version": 2, "browser_specific_settings": { "gecko": { - "id": "turndown@movenext.me" + "id": "browser_extension@movenext.me" } }, - "name": "Converts html to markdown", + "name": "Misc extensions", "version": "1.0", "background": { "scripts": [ "readability.min.js", - "background.js" + "port.js", + "turndown.js", + "feed.js" ] }, "permissions": [ "geckoViewAddons", "nativeMessaging", + "webRequest", + "webRequestBlocking", "" ] } \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/port.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/port.js new file mode 100644 index 00000000..33a7aa93 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/port.js @@ -0,0 +1 @@ +const port = browser.runtime.connectNative("mozacBrowserExtension"); \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/turndown/readability.min.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/readability.min.js similarity index 100% rename from packages/flutter_mozilla_components/android/src/main/assets/extensions/turndown/readability.min.js rename to packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/readability.min.js diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/turndown/background.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/turndown.js similarity index 92% rename from packages/flutter_mozilla_components/android/src/main/assets/extensions/turndown/background.js rename to packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/turndown.js index 55c295f6..6c11afe0 100644 --- a/packages/flutter_mozilla_components/android/src/main/assets/extensions/turndown/background.js +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/turndown.js @@ -1,4 +1,3 @@ -const port = browser.runtime.connectNative("mozacTurndownHtml"); const parser = new DOMParser(); port.onMessage.addListener(message => { @@ -15,6 +14,7 @@ port.onMessage.addListener(message => { }); port.postMessage({ + "type": "turndown", "id": requestId, "status": "success", "result": results diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/Components.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/Components.kt index 17a56123..fff9ab53 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/Components.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/Components.kt @@ -8,6 +8,7 @@ import eu.lensai.flutter_mozilla_components.components.Features import eu.lensai.flutter_mozilla_components.components.Search import eu.lensai.flutter_mozilla_components.components.Services import eu.lensai.flutter_mozilla_components.components.UseCases +import eu.lensai.flutter_mozilla_components.pigeons.BrowserExtensionEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoAddonEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoStateEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestionEvents @@ -21,10 +22,11 @@ class Components(private val context: Context, val flutterEvents: GeckoStateEvents, val readerViewController: ReaderViewController, val selectionAction: SelectionActionDelegate, - val addonEvents: GeckoAddonEvents, - val tabContentEvents: GeckoTabContentEvents + private val addonEvents: GeckoAddonEvents, + private val tabContentEvents: GeckoTabContentEvents, + private val extensionEvents: BrowserExtensionEvents ) { - val core by lazy { Core(context, this, flutterEvents) } + val core by lazy { Core(context, this, flutterEvents, extensionEvents) } val events by lazy { Events(flutterEvents) } val useCases by lazy { UseCases(context, core.engine, core.store) } val services by lazy { Services(context, useCases.tabsUseCases) } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/EngineProvider.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/EngineProvider.kt index 79beb8ed..fedbffa4 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/EngineProvider.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/EngineProvider.kt @@ -8,7 +8,8 @@ import android.content.Context import eu.lensai.flutter_mozilla_components.feature.ContainerProxyFeature import eu.lensai.flutter_mozilla_components.feature.CookieManagerFeature import eu.lensai.flutter_mozilla_components.feature.PrefManagerFeature -import eu.lensai.flutter_mozilla_components.feature.TurndownFeature +import eu.lensai.flutter_mozilla_components.feature.BrowserExtensionFeature +import eu.lensai.flutter_mozilla_components.pigeons.BrowserExtensionEvents import mozilla.components.browser.engine.gecko.GeckoEngine import mozilla.components.browser.engine.gecko.fetch.GeckoViewFetchClient import mozilla.components.concept.engine.DefaultSettings @@ -44,7 +45,7 @@ object EngineProvider { return runtime!! } - fun createEngine(context: Context, defaultSettings: DefaultSettings): Engine { + fun createEngine(context: Context, defaultSettings: DefaultSettings, extensionEvents: BrowserExtensionEvents): Engine { Logger.debug("Creating Engine") val runtime = getOrCreateRuntime(context) @@ -53,7 +54,7 @@ object EngineProvider { CookieManagerFeature.install(it) PrefManagerFeature.install(it) ContainerProxyFeature.install(it) - TurndownFeature.install(it) + BrowserExtensionFeature.install(it, extensionEvents) } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt index cad71ad0..ed8b677f 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt @@ -2,6 +2,7 @@ package eu.lensai.flutter_mozilla_components import android.app.Activity import android.content.Intent +import android.view.View import androidx.fragment.app.FragmentActivity import eu.lensai.flutter_mozilla_components.activities.NotificationActivity import eu.lensai.flutter_mozilla_components.api.GeckoAddonsApiImpl @@ -18,11 +19,13 @@ import eu.lensai.flutter_mozilla_components.api.GeckoSelectionActionControllerIm import eu.lensai.flutter_mozilla_components.api.GeckoSessionApiImpl import eu.lensai.flutter_mozilla_components.api.GeckoSuggestionApiImpl import eu.lensai.flutter_mozilla_components.api.GeckoTabsApiImpl -import eu.lensai.flutter_mozilla_components.api.GeckoTurndownApiImpl +import eu.lensai.flutter_mozilla_components.api.GeckoBrowserExtensionApiImpl import eu.lensai.flutter_mozilla_components.feature.DefaultSelectionActionDelegate +import eu.lensai.flutter_mozilla_components.pigeons.BrowserExtensionEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoAddonEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoAddonsApi import eu.lensai.flutter_mozilla_components.pigeons.GeckoBrowserApi +import eu.lensai.flutter_mozilla_components.pigeons.GeckoBrowserExtensionApi import eu.lensai.flutter_mozilla_components.pigeons.GeckoContainerProxyApi import eu.lensai.flutter_mozilla_components.pigeons.GeckoCookieApi import eu.lensai.flutter_mozilla_components.pigeons.GeckoDeleteBrowsingDataController @@ -39,7 +42,6 @@ import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestionApi import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestionEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoTabContentEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoTabsApi -import eu.lensai.flutter_mozilla_components.pigeons.GeckoTurndownApi import eu.lensai.flutter_mozilla_components.pigeons.ReaderViewController import eu.lensai.flutter_mozilla_components.pigeons.ReaderViewEvents @@ -68,7 +70,6 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { private lateinit var _flutterEvents : GeckoStateEvents private var isPlatformViewRegistered = false - private var pendingFragmentShow = false override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { synchronized(this) { @@ -92,6 +93,8 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { val readerViewController = ReaderViewController(_flutterPluginBinding.binaryMessenger) + val extensionEvents = BrowserExtensionEvents(_flutterPluginBinding.binaryMessenger) + val addonEvents = GeckoAddonEvents(_flutterPluginBinding.binaryMessenger) val tabContentEvents = GeckoTabContentEvents(_flutterPluginBinding.binaryMessenger) @@ -105,6 +108,7 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { selectionActionDelegate, addonEvents, tabContentEvents, + extensionEvents ) GeckoBrowserApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoBrowserApiImpl { @@ -125,7 +129,7 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { )) GeckoDeleteBrowsingDataController.setUp(_flutterPluginBinding.binaryMessenger, GeckoDeleteBrowsingDataControllerImpl()) GeckoDownloadsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoDownloadsApiImpl()) - GeckoTurndownApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTurndownApiImpl()) + GeckoBrowserExtensionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoBrowserExtensionApiImpl()) ReaderViewEvents.setUp( _flutterPluginBinding.binaryMessenger, @@ -137,21 +141,31 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { flutterPluginBinding.applicationContext.startActivity(intent) } - private fun showNativeFragment() { + private fun showNativeFragment(): Boolean { if (!isPlatformViewRegistered) { - pendingFragmentShow = true - return + return false } - if (activity == null) { - return + if (activity == null || activity !is FragmentActivity) { + return false + } + + val fragmentActivity = activity as FragmentActivity + + // Check if the container view exists in the view hierarchy + val container = fragmentActivity.findViewById(FRAGMENT_CONTAINER_ID) + if (container == null) { + // Container doesn't exist yet, retry later + return false } val nativeFragment = BrowserFragment.create() - val fm = (activity as FragmentActivity).supportFragmentManager + val fm = fragmentActivity.supportFragmentManager fm.beginTransaction() .replace(FRAGMENT_CONTAINER_ID, nativeFragment) .commitAllowingStateLoss() + + return true } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -170,12 +184,6 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { ) isPlatformViewRegistered = true - - // Process any pending fragment show request - if (pendingFragmentShow) { - pendingFragmentShow = false - showNativeFragment() - } } override fun onDetachedFromActivityForConfigChanges() { @@ -189,6 +197,5 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { override fun onDetachedFromActivity() { this.activity = null isPlatformViewRegistered = false - pendingFragmentShow = false } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/GeckoPlatformView.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/GeckoPlatformView.kt index e77dfa1f..917cd0f1 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/GeckoPlatformView.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/GeckoPlatformView.kt @@ -37,7 +37,13 @@ private class NativeFragmentView( FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) - container = FrameLayout(activity!!) + + // Ensure activity is not null before creating the container + if (activity == null) { + throw IllegalStateException("Activity cannot be null when creating NativeFragmentView") + } + + container = FrameLayout(activity) container.layoutParams = vParams container.id = containerId } @@ -45,8 +51,8 @@ private class NativeFragmentView( override fun onFlutterViewAttached(flutterView: View) { super.onFlutterViewAttached(flutterView) - components.engineReportedInitialized = false; - flutterEvents.onViewReadyStateChange(System.currentTimeMillis(),true) { _ -> } + components.engineReportedInitialized = false + flutterEvents.onViewReadyStateChange(System.currentTimeMillis(), true) { _ -> } } override fun getView(): View { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/GlobalComponents.kt index 580d92fe..c0759901 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/GlobalComponents.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/GlobalComponents.kt @@ -1,6 +1,7 @@ package eu.lensai.flutter_mozilla_components import android.content.Context +import eu.lensai.flutter_mozilla_components.pigeons.BrowserExtensionEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoAddonEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoStateEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestionEvents @@ -44,7 +45,8 @@ object GlobalComponents { readerViewController: ReaderViewController, selectionAction: SelectionActionDelegate, addonEvents: GeckoAddonEvents, - tabContentEvents: GeckoTabContentEvents + tabContentEvents: GeckoTabContentEvents, + extensionEvents: BrowserExtensionEvents ) { Logger.debug("Creating new components") @@ -54,7 +56,8 @@ object GlobalComponents { readerViewController, selectionAction, addonEvents, - tabContentEvents + tabContentEvents, + extensionEvents ) //newComponents.crashReporter.install(applicationContext) diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt index dbdb1262..8163a642 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt @@ -9,17 +9,19 @@ import mozilla.components.feature.addons.logger * Implementation of GeckoBrowserApi that handles browser-related operations * @param showFragmentCallback Callback function to show native fragment */ -class GeckoBrowserApiImpl(private val showFragmentCallback: () -> Unit) : GeckoBrowserApi { +class GeckoBrowserApiImpl(private val showFragmentCallback: () -> Boolean) : GeckoBrowserApi { companion object { private const val TAG = "GeckoBrowserApiImpl" } - override fun showNativeFragment() { + override fun showNativeFragment(): Boolean { try { - showFragmentCallback() + return showFragmentCallback() } catch (e: Exception) { logger.error("Failed to show native fragment", e) } + + return false } override fun onTrimMemory(level: Long) { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoTurndownApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoBrowserExtensionApiImpl.kt similarity index 84% rename from packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoTurndownApiImpl.kt rename to packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoBrowserExtensionApiImpl.kt index 1ddad9c7..1b7d6638 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoTurndownApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoBrowserExtensionApiImpl.kt @@ -1,12 +1,12 @@ package eu.lensai.flutter_mozilla_components.api import eu.lensai.flutter_mozilla_components.feature.ResultConsumer -import eu.lensai.flutter_mozilla_components.feature.TurndownFeature -import eu.lensai.flutter_mozilla_components.pigeons.GeckoTurndownApi +import eu.lensai.flutter_mozilla_components.feature.BrowserExtensionFeature +import eu.lensai.flutter_mozilla_components.pigeons.GeckoBrowserExtensionApi import org.json.JSONArray import org.json.JSONObject -class GeckoTurndownApiImpl : GeckoTurndownApi { +class GeckoBrowserExtensionApiImpl : GeckoBrowserExtensionApi { private fun JSONObject.toMap(): Map { val map = mutableMapOf() val keys = this.keys() @@ -38,7 +38,7 @@ class GeckoTurndownApiImpl : GeckoTurndownApi { } override fun getMarkdown(htmlList: List, callback: (Result>) -> Unit) { - TurndownFeature.scheduleRequest("turndown", htmlList, object : + BrowserExtensionFeature.scheduleRequest("turndown", htmlList, object : ResultConsumer { override fun success(result: JSONObject) { val resultArray = result.getJSONArray("result") diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/components/Core.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/components/Core.kt index 5f4122cb..38257d66 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/components/Core.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/components/Core.kt @@ -14,6 +14,7 @@ import eu.lensai.flutter_mozilla_components.activities.NotificationActivity import eu.lensai.flutter_mozilla_components.R import eu.lensai.flutter_mozilla_components.ext.getPreferenceKey import eu.lensai.flutter_mozilla_components.middleware.FlutterEventMiddleware +import eu.lensai.flutter_mozilla_components.pigeons.BrowserExtensionEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoStateEvents import kotlinx.coroutines.FlowPreview import mozilla.components.browser.engine.gecko.permission.GeckoSitePermissionsStorage @@ -54,6 +55,7 @@ private const val DAY_IN_MINUTES = 24 * 60L class Core(private val context: Context, private val components: Components, private val flutterEvents: GeckoStateEvents, + private val extensionEvents: BrowserExtensionEvents ) { val prefs by lazy { PreferenceManager.getDefaultSharedPreferences(context) @@ -95,7 +97,7 @@ class Core(private val context: Context, } val engine: Engine by lazy { - EngineProvider.createEngine(context, engineSettings) + EngineProvider.createEngine(context, engineSettings, extensionEvents) } /** diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/feature/TurndownFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/feature/BrowserExtensionFeature.kt similarity index 50% rename from packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/feature/TurndownFeature.kt rename to packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/feature/BrowserExtensionFeature.kt index ed450877..ae7e6174 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/feature/TurndownFeature.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/feature/BrowserExtensionFeature.kt @@ -1,6 +1,7 @@ package eu.lensai.flutter_mozilla_components.feature import androidx.annotation.VisibleForTesting +import eu.lensai.flutter_mozilla_components.pigeons.BrowserExtensionEvents import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex @@ -13,13 +14,15 @@ import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.webextensions.WebExtensionController import org.json.JSONArray import org.json.JSONObject +import org.mozilla.gecko.util.ThreadUtils.runOnUiThread -object TurndownFeature { - private val logger = Logger("turndown") +object BrowserExtensionFeature { + private val logger = Logger("browser_extension") - private const val PREF_MANAGER_REPORTER_EXTENSION_ID = "turndown@movenext.me" - private const val PREF_MANAGER_REPORTER_EXTENSION_URL = "resource://android/assets/extensions/turndown/" - private const val PREF_MANAGER_REPORTER_MESSAGING_ID = "mozacTurndownHtml" + private const val PREF_MANAGER_REPORTER_EXTENSION_ID = "browser_extension@movenext.me" + private const val PREF_MANAGER_REPORTER_EXTENSION_URL = + "resource://android/assets/extensions/browser_extension/" + private const val PREF_MANAGER_REPORTER_MESSAGING_ID = "mozacBrowserExtension" private var nextRequestId: Int = 0 private val requestHandlers = HashMap>() @@ -30,16 +33,22 @@ object TurndownFeature { internal var extensionController = WebExtensionController( PREF_MANAGER_REPORTER_EXTENSION_ID, PREF_MANAGER_REPORTER_EXTENSION_URL, - PREF_MANAGER_REPORTER_MESSAGING_ID, + PREF_MANAGER_REPORTER_MESSAGING_ID ) - fun scheduleRequest(command: String, args: Any, callback: ResultConsumer) { + fun scheduleRequest( + command: String, + args: Any, + callback: ResultConsumer + ) { val message = JSONObject() message.put("action", command); - message.put("args", when (args) { - is List<*> -> JSONArray(args) - else -> args - }) + message.put( + "args", when (args) { + is List<*> -> JSONArray(args) + else -> args + } + ) runBlocking { withContext(Dispatchers.Default) { @@ -56,23 +65,37 @@ object TurndownFeature { } } - private class TurndownBackgroundMessageHandler() : MessageHandler { + private class ExtensionBackgroundMessageHandler( + private val extensionEvents: BrowserExtensionEvents + ) : MessageHandler { override fun onPortMessage(message: Any, port: Port) { runBlocking { withContext(Dispatchers.Default) { mutex.withLock { val messageJSON = message as JSONObject; + val type = messageJSON.getString("type") - val requestId = messageJSON.getInt("id") - val status = messageJSON.getString("status") - if (status == "success") { - requestHandlers[requestId]?.success(message) - } else { - requestHandlers[requestId]?.error( - "Pref Manager", - "Failed to perform operation", - message.getString("error") - ) + if (type == "feedRequest") { + val url = messageJSON.getString("url") + + runOnUiThread { + extensionEvents.onFeedRequested( + System.currentTimeMillis(), + url + ) { _ -> } + } + } else if (type == "turndown") { + val requestId = messageJSON.getInt("id") + val status = messageJSON.getString("status") + if (status == "success") { + requestHandlers[requestId]?.success(message) + } else { + requestHandlers[requestId]?.error( + "Pref Manager", + "Failed to perform operation", + message.getString("error") + ) + } } } } @@ -87,17 +110,17 @@ object TurndownFeature { * @param productName a custom product name used to automatically label reports. Defaults to * "android-components". */ - fun install(runtime: WebExtensionRuntime) { + fun install(runtime: WebExtensionRuntime, extensionEvents: BrowserExtensionEvents) { extensionController.registerBackgroundMessageHandler( - TurndownBackgroundMessageHandler(), + ExtensionBackgroundMessageHandler(extensionEvents) ) extensionController.install( runtime, onSuccess = { - logger.debug("Installed Turndown webextension: ${it.id}") + logger.debug("Installed browser_extension webextension: ${it.id}") }, onError = { throwable -> - logger.error("Failed to install Turndown webextension: ", throwable) + logger.error("Failed to install browser_extension webextension: ", throwable) }, ) } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt index ca618d10..a2331697 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -1994,7 +1994,7 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface GeckoBrowserApi { - fun showNativeFragment() + fun showNativeFragment(): Boolean fun onTrimMemory(level: Long) companion object { @@ -2011,8 +2011,7 @@ interface GeckoBrowserApi { if (api != null) { channel.setMessageHandler { _, reply -> val wrapped: List = try { - api.showNativeFragment() - listOf(null) + listOf(api.showNativeFragment()) } catch (exception: Throwable) { wrapError(exception) } @@ -2980,20 +2979,20 @@ interface GeckoPrefApi { } } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ -interface GeckoTurndownApi { +interface GeckoBrowserExtensionApi { fun getMarkdown(htmlList: List, callback: (Result>) -> Unit) companion object { - /** The codec used by GeckoTurndownApi. */ + /** The codec used by GeckoBrowserExtensionApi. */ val codec: MessageCodec by lazy { GeckoPigeonCodec() } - /** Sets up an instance of `GeckoTurndownApi` to handle messages through the `binaryMessenger`. */ + /** Sets up an instance of `GeckoBrowserExtensionApi` to handle messages through the `binaryMessenger`. */ @JvmOverloads - fun setUp(binaryMessenger: BinaryMessenger, api: GeckoTurndownApi?, messageChannelSuffix: String = "") { + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoBrowserExtensionApi?, messageChannelSuffix: String = "") { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTurndownApi.getMarkdown$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserExtensionApi.getMarkdown$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { message, reply -> val args = message as List @@ -4010,3 +4009,29 @@ interface GeckoDownloadsApi { } } } +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class BrowserExtensionEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by BrowserExtensionEvents. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + } + fun onFeedRequested(timestampArg: Long, urlArg: String, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.BrowserExtensionEvents.onFeedRequested$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, urlArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(createConnectionError(channelName))) + } + } + } +} diff --git a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart index ca325035..da842e79 100644 --- a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart +++ b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart @@ -18,7 +18,7 @@ export 'src/domain/services/gecko_session.dart'; export 'src/domain/services/gecko_suggestions.dart'; export 'src/domain/services/gecko_tab.dart'; export 'src/domain/services/gecko_tab_content.dart'; -export 'src/domain/services/gecko_turndown.dart'; +export 'src/domain/services/gecko_browser_extension.dart'; export 'src/geckoview_widget.dart'; export 'src/pigeons/gecko.g.dart' show diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart index 5c67b46a..2db71825 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart @@ -7,7 +7,7 @@ class GeckoBrowserService { GeckoBrowserService({GeckoBrowserApi? api}) : _api = api ?? _apiInstance; - Future showNativeFragment() { + Future showNativeFragment() { return _api.showNativeFragment(); } diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser_extension.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser_extension.dart new file mode 100644 index 00000000..4888fbf8 --- /dev/null +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser_extension.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_mozilla_components/src/domain/entities/turndown_result.dart'; +import 'package:flutter_mozilla_components/src/extensions/subject.dart'; +import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart'; +import 'package:rxdart/rxdart.dart'; + +final _apiInstance = GeckoBrowserExtensionApi(); + +class GeckoBrowserExtensionService extends BrowserExtensionEvents { + final _feedRequest = BehaviorSubject(); + + Stream get feedRequested => _feedRequest.stream; + + static Future> turndownHtml( + List htmlList, + ) async { + final markdownResult = await _apiInstance.getMarkdown(htmlList); + + final results = + markdownResult + .cast() + .map( + (result) => TurndownResults( + // ignore: avoid_dynamic_calls valid + markdown: result['fullContentMarkdown'] as String, + // ignore: avoid_dynamic_calls valid + plain: result['fullContentPlain'] as String, + ), + ) + .toList(); + + return results; + } + + GeckoBrowserExtensionService.setUp({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { + BrowserExtensionEvents.setUp( + this, + binaryMessenger: binaryMessenger, + messageChannelSuffix: messageChannelSuffix, + ); + } + + @override + void onFeedRequested(int timestamp, String url) { + _feedRequest.addWhenMoreRecent(timestamp, null, url); + } + + void dispose() { + unawaited(_feedRequest.close()); + } +} diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_turndown.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_turndown.dart deleted file mode 100644 index ccfa6821..00000000 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_turndown.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter_mozilla_components/src/domain/entities/turndown_result.dart'; -import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart'; - -final _apiInstance = GeckoTurndownApi(); - -class GeckoTurndownService { - Future> turndownHtml(List htmlList) async { - final markdownResult = await _apiInstance.getMarkdown(htmlList); - - final results = - markdownResult - .cast() - .map( - (result) => TurndownResults( - // ignore: avoid_dynamic_calls valid - markdown: result['fullContentMarkdown'] as String, - // ignore: avoid_dynamic_calls valid - plain: result['fullContentPlain'] as String, - ), - ) - .toList(); - - return results; - } -} diff --git a/packages/flutter_mozilla_components/lib/src/geckoview_widget.dart b/packages/flutter_mozilla_components/lib/src/geckoview_widget.dart index c7594fae..be16c3ca 100644 --- a/packages/flutter_mozilla_components/lib/src/geckoview_widget.dart +++ b/packages/flutter_mozilla_components/lib/src/geckoview_widget.dart @@ -43,6 +43,29 @@ class _GeckoViewState extends State { }); } + Future _showNativeFragment({ + int maxRetries = 100, + + /// Default ist about one frame + Duration retryDelay = const Duration(milliseconds: 1000 ~/ 60), + }) async { + for (int attempt = 0; attempt < maxRetries; attempt++) { + final result = await browserService.showNativeFragment(); + + if (result) { + debugPrint('Fragment ATTACHED after $attempt tries'); + return true; + } + + if (attempt < maxRetries - 1) { + await Future.delayed(retryDelay); + } + } + + debugPrint('Fragment FAILED after $maxRetries tries'); + return false; + } + @override Widget build(BuildContext context) { return PlatformViewLink( @@ -68,13 +91,8 @@ class _GeckoViewState extends State { SchedulerBinding.instance.addPostFrameCallback((_) async { await widget.preInitializationStep?.call(); - await Future.delayed( - //Wait for two more frames just to be sure view has been initialized - Duration(milliseconds: ((1000 / 60) * 2).toInt()), - ).whenComplete(() async { - await browserService.showNativeFragment(); - await widget.postInitializationStep?.call(); - }); + await _showNativeFragment(); + await widget.postInitializationStep?.call(); }); }) // ignore: discarded_futures that hos it is done in docs diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index 88f9003a..7281d6a8 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -1983,7 +1983,7 @@ class GeckoBrowserApi { final String pigeonVar_messageChannelSuffix; - Future showNativeFragment() async { + Future showNativeFragment() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.showNativeFragment$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, @@ -2001,8 +2001,13 @@ class GeckoBrowserApi { message: pigeonVar_replyList[1] as String?, details: pigeonVar_replyList[2], ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); } else { - return; + return (pigeonVar_replyList[0] as bool?)!; } } @@ -3123,11 +3128,11 @@ class GeckoPrefApi { } } -class GeckoTurndownApi { - /// Constructor for [GeckoTurndownApi]. The [binaryMessenger] named argument is +class GeckoBrowserExtensionApi { + /// Constructor for [GeckoBrowserExtensionApi]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. - GeckoTurndownApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + GeckoBrowserExtensionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; final BinaryMessenger? pigeonVar_binaryMessenger; @@ -3137,7 +3142,7 @@ class GeckoTurndownApi { final String pigeonVar_messageChannelSuffix; Future> getMarkdown(List htmlList) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoTurndownApi.getMarkdown$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserExtensionApi.getMarkdown$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -4529,3 +4534,41 @@ class GeckoDownloadsApi { } } } + +abstract class BrowserExtensionEvents { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + void onFeedRequested(int timestamp, String url); + + static void setUp(BrowserExtensionEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.flutter_mozilla_components.BrowserExtensionEvents.onFeedRequested$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.BrowserExtensionEvents.onFeedRequested was null.'); + final List args = (message as List?)!; + final int? arg_timestamp = (args[0] as int?); + assert(arg_timestamp != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.BrowserExtensionEvents.onFeedRequested was null, expected non-null int.'); + final String? arg_url = (args[1] as String?); + assert(arg_url != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.BrowserExtensionEvents.onFeedRequested was null, expected non-null String.'); + try { + api.onFeedRequested(arg_timestamp!, arg_url!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } +} diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index ccda8fb8..c3b16b82 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -736,7 +736,7 @@ class ShareInternetResourceState { ) @HostApi() abstract class GeckoBrowserApi { - void showNativeFragment(); + bool showNativeFragment(); void onTrimMemory(int level); } @@ -943,7 +943,7 @@ abstract class GeckoPrefApi { } @HostApi() -abstract class GeckoTurndownApi { +abstract class GeckoBrowserExtensionApi { @async List getMarkdown(List htmlList); } @@ -1122,3 +1122,8 @@ abstract class GeckoDownloadsApi { void copyInternetResource(String tabId, ShareInternetResourceState state); void shareInternetResource(String tabId, ShareInternetResourceState state); } + +@FlutterApi() +abstract class BrowserExtensionEvents { + void onFeedRequested(int timestamp, String url); +}