major update

This commit is contained in:
Fabian Freund
2025-03-03 15:15:05 +01:00
parent 200c82a442
commit 20bdc4df3e
103 changed files with 2664 additions and 783 deletions
+17 -16
View File
@@ -36,17 +36,14 @@ class BrowserRoute extends GoRouteData {
class WebPageRoute extends GoRouteData { class WebPageRoute extends GoRouteData {
final String url; final String url;
final WebPageInfo? $extra;
const WebPageRoute({required this.url}); const WebPageRoute({required this.url, required this.$extra});
@override @override
Page<void> buildPage(BuildContext context, GoRouterState state) { Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage( return DialogPage(
builder: builder: (_) => WebPageDialog(url: Uri.parse(url), precachedInfo: $extra),
(_) => WebPageDialog(
url: Uri.parse(url),
precachedInfo: state.extra as WebPageInfo?,
),
); );
} }
} }
@@ -85,33 +82,37 @@ class ContainerListRoute extends GoRouteData {
} }
class ContainerEditRoute extends GoRouteData { class ContainerEditRoute extends GoRouteData {
final ContainerData $extra;
ContainerEditRoute(this.$extra);
@override @override
Widget build(BuildContext context, GoRouterState state) { Widget build(BuildContext context, GoRouterState state) {
return ContainerEditScreen.edit( return ContainerEditScreen.edit(initialContainer: $extra);
initialContainer: state.extra! as ContainerData,
);
} }
} }
class ContainerCreateRoute extends GoRouteData { class ContainerCreateRoute extends GoRouteData {
final ContainerData $extra;
ContainerCreateRoute(this.$extra);
@override @override
Widget build(BuildContext context, GoRouterState state) { Widget build(BuildContext context, GoRouterState state) {
return ContainerEditScreen.create( return ContainerEditScreen.create(initialContainer: $extra);
initialContainer: state.extra! as ContainerData,
);
} }
} }
class ContextMenuRoute extends GoRouteData { class ContextMenuRoute extends GoRouteData {
const ContextMenuRoute(); final String $extra;
const ContextMenuRoute(this.$extra);
@override @override
Page<void> buildPage(BuildContext context, GoRouterState state) { Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage( return DialogPage(
builder: builder:
(_) => ContextMenuDialog( (_) => ContextMenuDialog(hitResult: HitResultJson.fromJson($extra)),
hitResult: HitResultJson.fromJson(state.extra! as String),
),
); );
} }
} }
+4 -1
View File
@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:lensai/core/routing/widgets/dialog_page.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/settings/presentation/screens/web_engine_settings.dart';
import 'package:lensai/features/tor/presentation/screens/tor_proxy.dart'; import 'package:lensai/features/tor/presentation/screens/tor_proxy.dart';
import 'package:lensai/features/user/presentation/screens/auth.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.dart';
import 'package:lensai/features/web_feed/presentation/screens/feed_article_list.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_edit.dart';
import 'package:lensai/features/web_feed/presentation/screens/feed_list.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.g.dart';
part 'routes.settings.dart'; part 'routes.settings.dart';
+54 -2
View File
@@ -4,6 +4,7 @@ part of 'routes.dart';
name: 'FeedListRoute', name: 'FeedListRoute',
path: '/feeds', path: '/feeds',
routes: [ routes: [
TypedGoRoute<FeedAddRoute>(name: 'FeedAddRoute', path: 'add'),
TypedGoRoute<FeedArticleListRoute>( TypedGoRoute<FeedArticleListRoute>(
name: 'FeedArticleListRoute', name: 'FeedArticleListRoute',
path: 'articles/:feedId', path: 'articles/:feedId',
@@ -12,7 +13,15 @@ part of 'routes.dart';
name: 'FeedArticleRoute', name: 'FeedArticleRoute',
path: 'article/:articleId', path: 'article/:articleId',
), ),
TypedGoRoute<FeedCreateRoute>(name: 'FeedCreateRoute', path: 'create'), TypedGoRoute<FeedCreateRoute>(
name: 'FeedCreateRoute',
path: 'create/:feedId',
),
TypedGoRoute<SelectFeedDialogRoute>(
name: 'SelectFeedDialogRoute',
path: 'available/:feedsJson',
),
TypedGoRoute<FeedEditRoute>(name: 'FeedEditRoute', path: 'edit/:feedId'),
], ],
) )
class FeedListRoute extends GoRouteData { class FeedListRoute extends GoRouteData {
@@ -23,9 +32,52 @@ class FeedListRoute extends GoRouteData {
} }
class FeedCreateRoute extends GoRouteData { class FeedCreateRoute extends GoRouteData {
final Uri feedId;
FeedCreateRoute({required this.feedId});
@override @override
Widget build(BuildContext context, GoRouterState state) { 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<void> buildPage(BuildContext context, GoRouterState state) {
final feedUris = Set<Uri>.from(
(jsonDecode(feedsJson) as List<dynamic>).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<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage(builder: (_) => AddFeedDialog(initialUri: $extra));
} }
} }
+109 -24
View File
@@ -270,20 +270,24 @@ extension $BrowserRouteExtension on BrowserRoute {
} }
extension $WebPageRouteExtension on WebPageRoute { extension $WebPageRouteExtension on WebPageRoute {
static WebPageRoute _fromState(GoRouterState state) => static WebPageRoute _fromState(GoRouterState state) => WebPageRoute(
WebPageRoute(url: state.pathParameters['url']!); url: state.pathParameters['url']!,
$extra: state.extra as WebPageInfo?,
);
String get location => String get location =>
GoRouteData.$location('/page/${Uri.encodeComponent(url)}'); GoRouteData.$location('/page/${Uri.encodeComponent(url)}');
void go(BuildContext context) => context.go(location); void go(BuildContext context) => context.go(location, extra: $extra);
Future<T?> push<T>(BuildContext context) => context.push<T>(location); Future<T?> push<T>(BuildContext context) =>
context.push<T>(location, extra: $extra);
void pushReplacement(BuildContext context) => 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 { extension $SearchRouteExtension on SearchRoute {
@@ -322,18 +326,20 @@ extension $TorProxyRouteExtension on TorProxyRoute {
extension $ContextMenuRouteExtension on ContextMenuRoute { extension $ContextMenuRouteExtension on ContextMenuRoute {
static ContextMenuRoute _fromState(GoRouterState state) => static ContextMenuRoute _fromState(GoRouterState state) =>
const ContextMenuRoute(); ContextMenuRoute(state.extra as String);
String get location => GoRouteData.$location('/context_menu'); String get location => GoRouteData.$location('/context_menu');
void go(BuildContext context) => context.go(location); void go(BuildContext context) => context.go(location, extra: $extra);
Future<T?> push<T>(BuildContext context) => context.push<T>(location); Future<T?> push<T>(BuildContext context) =>
context.push<T>(location, extra: $extra);
void pushReplacement(BuildContext context) => 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 { extension $ContainerListRouteExtension on ContainerListRoute {
@@ -354,34 +360,38 @@ extension $ContainerListRouteExtension on ContainerListRoute {
extension $ContainerCreateRouteExtension on ContainerCreateRoute { extension $ContainerCreateRouteExtension on ContainerCreateRoute {
static ContainerCreateRoute _fromState(GoRouterState state) => static ContainerCreateRoute _fromState(GoRouterState state) =>
ContainerCreateRoute(); ContainerCreateRoute(state.extra as ContainerData);
String get location => GoRouteData.$location('/containers/create'); String get location => GoRouteData.$location('/containers/create');
void go(BuildContext context) => context.go(location); void go(BuildContext context) => context.go(location, extra: $extra);
Future<T?> push<T>(BuildContext context) => context.push<T>(location); Future<T?> push<T>(BuildContext context) =>
context.push<T>(location, extra: $extra);
void pushReplacement(BuildContext context) => 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 { extension $ContainerEditRouteExtension on ContainerEditRoute {
static ContainerEditRoute _fromState(GoRouterState state) => static ContainerEditRoute _fromState(GoRouterState state) =>
ContainerEditRoute(); ContainerEditRoute(state.extra as ContainerData);
String get location => GoRouteData.$location('/containers/edit'); String get location => GoRouteData.$location('/containers/edit');
void go(BuildContext context) => context.go(location); void go(BuildContext context) => context.go(location, extra: $extra);
Future<T?> push<T>(BuildContext context) => context.push<T>(location); Future<T?> push<T>(BuildContext context) =>
context.push<T>(location, extra: $extra);
void pushReplacement(BuildContext context) => 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( RouteBase get $bangCategoriesRoute => GoRouteData.$route(
@@ -492,6 +502,12 @@ RouteBase get $feedListRoute => GoRouteData.$route(
factory: $FeedListRouteExtension._fromState, factory: $FeedListRouteExtension._fromState,
routes: [ routes: [
GoRouteData.$route(
path: 'add',
name: 'FeedAddRoute',
factory: $FeedAddRouteExtension._fromState,
),
GoRouteData.$route( GoRouteData.$route(
path: 'articles/:feedId', path: 'articles/:feedId',
name: 'FeedArticleListRoute', name: 'FeedArticleListRoute',
@@ -505,11 +521,23 @@ RouteBase get $feedListRoute => GoRouteData.$route(
factory: $FeedArticleRouteExtension._fromState, factory: $FeedArticleRouteExtension._fromState,
), ),
GoRouteData.$route( GoRouteData.$route(
path: 'create', path: 'create/:feedId',
name: 'FeedCreateRoute', name: 'FeedCreateRoute',
factory: $FeedCreateRouteExtension._fromState, 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); 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<T?> push<T>(BuildContext context) =>
context.push<T>(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 { extension $FeedArticleListRouteExtension on FeedArticleListRoute {
static FeedArticleListRoute _fromState(GoRouterState state) => static FeedArticleListRoute _fromState(GoRouterState state) =>
FeedArticleListRoute(feedId: Uri.parse(state.pathParameters['feedId']!)!); FeedArticleListRoute(feedId: Uri.parse(state.pathParameters['feedId']!)!);
@@ -564,9 +610,48 @@ extension $FeedArticleRouteExtension on FeedArticleRoute {
} }
extension $FeedCreateRouteExtension on FeedCreateRoute { 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<T?> push<T>(BuildContext context) => context.push<T>(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<T?> push<T>(BuildContext context) => context.push<T>(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); void go(BuildContext context) => context.go(location);
+1 -1
View File
@@ -10,7 +10,7 @@ class WebPageInfo with FastEquatable {
final Uri url; final Uri url;
final String? title; final String? title;
final BrowserIcon? favicon; final BrowserIcon? favicon;
final Set<String>? feeds; final Set<Uri>? feeds;
bool get isPageInfoComplete => bool get isPageInfoComplete =>
title.isNotEmpty && favicon != null && feeds != null; title.isNotEmpty && favicon != null && feeds != null;
+4 -4
View File
@@ -13,7 +13,7 @@ abstract class _$WebPageInfoCWProxy {
WebPageInfo favicon(BrowserIcon? favicon); WebPageInfo favicon(BrowserIcon? favicon);
WebPageInfo feeds(Set<String>? feeds); WebPageInfo feeds(Set<Uri>? 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. /// 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, Uri url,
String? title, String? title,
BrowserIcon? favicon, BrowserIcon? favicon,
Set<String>? feeds, Set<Uri>? feeds,
}); });
} }
@@ -45,7 +45,7 @@ class _$WebPageInfoCWProxyImpl implements _$WebPageInfoCWProxy {
WebPageInfo favicon(BrowserIcon? favicon) => this(favicon: favicon); WebPageInfo favicon(BrowserIcon? favicon) => this(favicon: favicon);
@override @override
WebPageInfo feeds(Set<String>? feeds) => this(feeds: feeds); WebPageInfo feeds(Set<Uri>? feeds) => this(feeds: feeds);
@override @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. /// 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() feeds == const $CopyWithPlaceholder()
? _value.feeds ? _value.feeds
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: feeds as Set<String>?, : feeds as Set<Uri>?,
); );
} }
} }
+45 -8
View File
@@ -186,9 +186,11 @@ class GenericWebsiteService extends _$GenericWebsiteService {
return icons; return icons;
} }
Future<Result<WebPageInfo>> fetchPageInfo(Uri url) { Future<Result<WebPageInfo>> fetchPageInfo(Uri url, bool isImageRequest) {
return Result.fromAsync(() async { 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(); final client = http.Client();
try { try {
final baseUri = Uri.parse(urlString); final baseUri = Uri.parse(urlString);
@@ -196,6 +198,16 @@ class GenericWebsiteService extends _$GenericWebsiteService {
.get(baseUri) .get(baseUri)
.timeout(const Duration(seconds: 15)); .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 document = html_parser.parse(response.body);
final title = document.querySelector('title')?.text; final title = document.querySelector('title')?.text;
@@ -206,12 +218,23 @@ class GenericWebsiteService extends _$GenericWebsiteService {
return { return {
'title': title, 'title': title,
'resources': resources.map(_serializeResource).toList(), 'resources': resources.map(_serializeResource).toList(),
'feeds': feeds, 'feeds': feeds.map((uri) => uri.toString()).toList(),
}; };
} finally { } finally {
client.close(); client.close();
} }
}, url.toString()); }, <dynamic>[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 = final resources =
(result['resources']! as List<Map<String, dynamic>>) (result['resources']! as List<Map<String, dynamic>>)
@@ -226,7 +249,9 @@ class GenericWebsiteService extends _$GenericWebsiteService {
url: url, url: url,
title: result['title'] as String?, title: result['title'] as String?,
favicon: favicon, favicon: favicon,
feeds: result['feeds'] as Set<String>?, feeds: Set.from(
(result['feeds']! as List<String>).map((url) => Uri.tryParse(url)),
),
); );
}, exceptionHandler: handleHttpError); }, exceptionHandler: handleHttpError);
} }
@@ -277,18 +302,30 @@ class GenericWebsiteService extends _$GenericWebsiteService {
); );
} }
Future<BrowserIcon> getUrlIcon(Uri url) async { Future<BrowserIcon?> getUrlIcon(List<Uri> urlList) async {
for (final url in urlList) {
final cachedIcon = await getCachedIcon(url); final cachedIcon = await getCachedIcon(url);
if (cachedIcon != null) { if (cachedIcon != null) {
return cachedIcon; return cachedIcon;
} }
final result = await ref.read(pageInfoProvider(url).future); final result = await ref.read(
final favicon = result.favicon!; 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; return favicon;
} }
}
return null;
}
// Future<Uri?> tryUpgradeToHttps(Uri httpUri) async { // Future<Uri?> tryUpgradeToHttps(Uri httpUri) async {
// if (httpUri.isScheme('https')) { // if (httpUri.isScheme('https')) {
@@ -7,7 +7,7 @@ part of 'generic_website.dart';
// ************************************************************************** // **************************************************************************
String _$genericWebsiteServiceHash() => String _$genericWebsiteServiceHash() =>
r'9d17f41596579c289a6cd021ed8d8049e1fb7aff'; r'9bba5a6fc01aa788b2674db6ae3869065d6a528d';
/// See also [GenericWebsiteService]. /// See also [GenericWebsiteService].
@ProviderFor(GenericWebsiteService) @ProviderFor(GenericWebsiteService)
+1 -1
View File
@@ -71,7 +71,7 @@ class Bang with FastEquatable implements Insertable<Bang> {
: input; : input;
} }
Uri getUrl(String? query) { Uri getTemplateUrl(String? query) {
final url = final url =
(query != null) (query != null)
? urlTemplate.replaceAll( ? urlTemplate.replaceAll(
@@ -22,7 +22,7 @@ class BangSearch extends _$BangSearch {
maxEntryCount: 3, maxEntryCount: 3,
); //TODO: make count dynamic ); //TODO: make count dynamic
return bang.getUrl(searchQuery); return bang.getTemplateUrl(searchQuery);
} }
Future<void> search(String input) async { Future<void> search(String input) async {
@@ -50,7 +50,7 @@ class BangSearch extends _$BangSearch {
} }
@Riverpod() @Riverpod()
class SeamlessBangProvider extends _$SeamlessBangProvider { class SeamlessBang extends _$SeamlessBang {
bool _hasSearch = false; bool _hasSearch = false;
void search(String input) { void search(String input) {
@@ -60,6 +60,7 @@ class SeamlessBangProvider extends _$SeamlessBangProvider {
ref.invalidateSelf(); ref.invalidateSelf();
} }
//Don't block
unawaited(ref.read(bangSearchProvider.notifier).search(input)); unawaited(ref.read(bangSearchProvider.notifier).search(input));
} else if (_hasSearch) { } else if (_hasSearch) {
_hasSearch = false; _hasSearch = false;
@@ -6,7 +6,7 @@ part of 'search.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$bangSearchHash() => r'ff7035d269041c6415f2a2afb0a71ef67d3b9841'; String _$bangSearchHash() => r'6b7452d48698c01870c5c75f0c10cadaafc481c8';
/// See also [BangSearch]. /// See also [BangSearch].
@ProviderFor(BangSearch) @ProviderFor(BangSearch)
@@ -23,26 +23,22 @@ final bangSearchProvider =
); );
typedef _$BangSearch = AutoDisposeStreamNotifier<List<BangData>>; typedef _$BangSearch = AutoDisposeStreamNotifier<List<BangData>>;
String _$seamlessBangProviderHash() => String _$seamlessBangHash() => r'8bd7a2cbe4c302ae08f85167290666a7437f8b9b';
r'c4c0b452c94ba447be5b02dfef5bc16148cc3dc7';
/// See also [SeamlessBangProvider]. /// See also [SeamlessBang].
@ProviderFor(SeamlessBangProvider) @ProviderFor(SeamlessBang)
final seamlessBangProviderProvider = AutoDisposeNotifierProvider< final seamlessBangProvider = AutoDisposeNotifierProvider<
SeamlessBangProvider, SeamlessBang,
AsyncValue<List<BangData>> AsyncValue<List<BangData>>
>.internal( >.internal(
SeamlessBangProvider.new, SeamlessBang.new,
name: r'seamlessBangProviderProvider', name: r'seamlessBangProvider',
debugGetCreateSourceHash: debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') const bool.fromEnvironment('dart.vm.product') ? null : _$seamlessBangHash,
? null
: _$seamlessBangProviderHash,
dependencies: null, dependencies: null,
allTransitiveDependencies: null, allTransitiveDependencies: null,
); );
typedef _$SeamlessBangProvider = typedef _$SeamlessBang = AutoDisposeNotifier<AsyncValue<List<BangData>>>;
AutoDisposeNotifier<AsyncValue<List<BangData>>>;
// ignore_for_file: type=lint // 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 // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -1,7 +1,6 @@
import 'package:fading_scroll/fading_scroll.dart'; import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/core/routing/routes.dart';
import 'package:lensai/features/bangs/domain/providers/bangs.dart'; import 'package:lensai/features/bangs/domain/providers/bangs.dart';
@@ -19,13 +18,14 @@ class BangCategoriesScreen extends HookConsumerWidget {
actions: [ actions: [
IconButton( IconButton(
onPressed: () async { onPressed: () async {
await context.push(const BangSearchRoute().location); await const BangSearchRoute().push(context);
}, },
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
), ),
], ],
), ),
body: categoriesAsync.when( body: categoriesAsync.when(
skipLoadingOnReload: true,
data: (categories) { data: (categories) {
return FadingScroll( return FadingScroll(
fadingSize: 25, fadingSize: 25,
@@ -66,13 +66,10 @@ class BangCategoriesScreen extends HookConsumerWidget {
(subCategory) => ListTile( (subCategory) => ListTile(
title: Text(subCategory), title: Text(subCategory),
onTap: () async { onTap: () async {
await context.push( await BangSubCategoryRoute(
BangSubCategoryRoute(
category: category.key, category: category.key,
subCategory: subCategory: subCategory,
subCategory, ).push(context);
).location,
);
}, },
), ),
) )
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/core/routing/routes.dart';
import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/extensions/nullable.dart';
@@ -29,6 +28,7 @@ class BangListScreen extends HookConsumerWidget {
slivers: [ slivers: [
SliverAppBar.medium(title: Text('$category: $subCategory')), SliverAppBar.medium(title: Text('$category: $subCategory')),
bangsAsync.when( bangsAsync.when(
skipLoadingOnReload: true,
data: (bangs) { data: (bangs) {
return SliverList.builder( return SliverList.builder(
itemCount: bangs.length, itemCount: bangs.length,
@@ -41,7 +41,7 @@ class BangListScreen extends HookConsumerWidget {
.read(selectedBangTriggerProvider().notifier) .read(selectedBangTriggerProvider().notifier)
.setTrigger(bang.trigger); .setTrigger(bang.trigger);
context.go(const SearchRoute().location); const SearchRoute().go(context);
}, },
); );
}, },
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/core/routing/routes.dart';
import 'package:lensai/features/bangs/data/models/bang_data.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart';
@@ -38,7 +37,7 @@ class BangDetails extends HookConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
UrlIcon(bangData.getUrl(''), iconSize: 34.0), UrlIcon([bangData.getTemplateUrl('')], iconSize: 34.0),
const SizedBox(width: 12.0), const SizedBox(width: 12.0),
Expanded( Expanded(
child: Column( child: Column(
@@ -67,14 +66,14 @@ class BangDetails extends HookConsumerWidget {
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
), ),
onPressed: () async { onPressed: () async {
final url = Uri.parse(bangData.getUrl('').origin); final url = Uri.parse(bangData.getTemplateUrl('').origin);
await ref await ref
.read(tabRepositoryProvider.notifier) .read(tabRepositoryProvider.notifier)
.addTab(url: url); .addTab(url: url);
if (context.mounted) { if (context.mounted) {
context.go(BrowserRoute().location); BrowserRoute().go(context);
} }
}, },
label: Text(bangData.domain), label: Text(bangData.domain),
@@ -73,7 +73,8 @@ class SiteSearch extends HookConsumerWidget {
width: double.maxFinite, width: double.maxFinite,
child: SelectableChips( child: SelectableChips(
itemId: (bang) => bang.trigger, itemId: (bang) => bang.trigger,
itemAvatar: (bang) => UrlIcon(bang.getUrl(''), iconSize: 20), itemAvatar:
(bang) => UrlIcon([bang.getTemplateUrl('')], iconSize: 20),
itemLabel: (bang) => Text(bang.websiteName), itemLabel: (bang) => Text(bang.websiteName),
availableItems: availableBangs, availableItems: availableBangs,
selectedItem: selectedBang, selectedItem: selectedBang,
@@ -29,7 +29,7 @@ GeckoSelectionActionService selectionActionService(Ref ref) {
await ref await ref
.read(tabRepositoryProvider.notifier) .read(tabRepositoryProvider.notifier)
.addTab( .addTab(
url: defaultSearchBang.getUrl(text), url: defaultSearchBang.getTemplateUrl(text),
parentId: currentTabId, parentId: currentTabId,
); );
} else { } else {
@@ -7,7 +7,7 @@ part of 'providers.dart';
// ************************************************************************** // **************************************************************************
String _$selectionActionServiceHash() => String _$selectionActionServiceHash() =>
r'8d0af53213bdb924bcf5f8df6ceec6c81a345345'; r'd907a3c5ab7efde82bb2cc1fb0409fd3fd13bb25';
/// See also [selectionActionService]. /// See also [selectionActionService].
@ProviderFor(selectionActionService) @ProviderFor(selectionActionService)
@@ -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<String> feedRequested(Ref ref) {
final service = ref.watch(browserExtensionServiceProvider);
return service.feedRequested;
}
@@ -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<GeckoBrowserExtensionService>.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<GeckoBrowserExtensionService>;
String _$feedRequestedHash() => r'4f179d0878072a77a87422ff6afdac53a3d57c04';
/// See also [feedRequested].
@ProviderFor(feedRequested)
final feedRequestedProvider = AutoDisposeStreamProvider<String>.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<String>;
// 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
@@ -162,7 +162,7 @@ class TabRepository extends _$TabRepository {
ref.read(selectedBangDataProvider()) ?? ref.read(selectedBangDataProvider()) ??
await ref.read(defaultSearchBangDataProvider.future); await ref.read(defaultSearchBangDataProvider.future);
await addTab(url: defaultSearchBang?.getUrl(value.text)); await addTab(url: defaultSearchBang?.getTemplateUrl(value.text));
} }
}); });
}); });
@@ -6,7 +6,7 @@ part of 'tab.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$tabRepositoryHash() => r'12e4fb7e9bf36a6df9f0a29df4b410f051d5c38e'; String _$tabRepositoryHash() => r'69e283b820d86388f4d80e71e08761e2b6890507';
/// See also [TabRepository]. /// See also [TabRepository].
@ProviderFor(TabRepository) @ProviderFor(TabRepository)
@@ -81,6 +81,7 @@ class WebPageDialog extends HookConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: availableBangsAsync.when( child: availableBangsAsync.when(
skipLoadingOnReload: true,
data: (availableBangs) { data: (availableBangs) {
if (availableBangs.isEmpty) { if (availableBangs.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -53,10 +53,7 @@ class BrowserScreen extends HookConsumerWidget {
useOnStreamChange( useOnStreamChange(
eventService.longPressEvent, eventService.longPressEvent,
onData: (event) async { onData: (event) async {
await context.push( await ContextMenuRoute(event.hitResult.toJson()).push(context);
const ContextMenuRoute().location,
extra: event.hitResult.toJson(),
);
}, },
); );
@@ -66,7 +66,7 @@ class AddressWithSuggestionsField extends HookConsumerWidget {
defaultSearchBangDataProvider.future, defaultSearchBangDataProvider.future,
); );
newUrl = defaultSearchBang?.getUrl(value); newUrl = defaultSearchBang?.getTemplateUrl(value);
} }
if (newUrl != null) { if (newUrl != null) {
@@ -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/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/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/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/hooks/menu_controller.dart';
import 'package:lensai/presentation/icons/tor_icons.dart'; import 'package:lensai/presentation/icons/tor_icons.dart';
import 'package:lensai/utils/ui_helper.dart' as ui_helper; import 'package:lensai/utils/ui_helper.dart' as ui_helper;
@@ -55,12 +54,10 @@ class BrowserBottomAppBar extends HookConsumerWidget {
? AppBarTitle( ? AppBarTitle(
tab: tabState, tab: tabState,
onTap: () async { onTap: () async {
await context.push( await WebPageRoute(
WebPageRoute(
url: tabState.url.toString(), url: tabState.url.toString(),
).location, $extra: tabState,
extra: tabState, ).push(context);
);
}, },
onLongPress: () async { onLongPress: () async {
final newUrl = await showDialog<Uri?>( final newUrl = await showDialog<Uri?>(
@@ -104,7 +101,7 @@ class BrowserBottomAppBar extends HookConsumerWidget {
), ),
MenuItemButton( MenuItemButton(
onPressed: () async { onPressed: () async {
await context.push(const SearchRoute().location); await const SearchRoute().push(context);
}, },
leadingIcon: const Icon(Icons.add), leadingIcon: const Icon(Icons.add),
child: const Text('Add Tab'), child: const Text('Add Tab'),
@@ -183,7 +180,7 @@ class BrowserBottomAppBar extends HookConsumerWidget {
), ),
MenuItemButton( MenuItemButton(
onPressed: () async { onPressed: () async {
await context.push(AboutRoute().location); await AboutRoute().push(context);
}, },
leadingIcon: const Icon(Icons.info), leadingIcon: const Icon(Icons.info),
child: const Text('About'), child: const Text('About'),
@@ -191,7 +188,7 @@ class BrowserBottomAppBar extends HookConsumerWidget {
const Divider(), const Divider(),
MenuItemButton( MenuItemButton(
onPressed: () async { onPressed: () async {
await context.push(SettingsRoute().location); await SettingsRoute().push(context);
}, },
leadingIcon: const Icon(Icons.settings), leadingIcon: const Icon(Icons.settings),
child: const Text('Settings'), child: const Text('Settings'),
@@ -234,7 +231,7 @@ class BrowserBottomAppBar extends HookConsumerWidget {
), ),
MenuItemButton( MenuItemButton(
onPressed: () async { onPressed: () async {
await context.push(TorProxyRoute().location); await TorProxyRoute().push(context);
}, },
leadingIcon: const Icon(TorIcons.onionAlt), leadingIcon: const Icon(TorIcons.onionAlt),
child: const Text('Tor'), child: const Text('Tor'),
@@ -242,11 +239,18 @@ class BrowserBottomAppBar extends HookConsumerWidget {
const Divider(), const Divider(),
MenuItemButton( MenuItemButton(
onPressed: () async { onPressed: () async {
await context.push(BangCategoriesRoute().location); await BangCategoriesRoute().push(context);
}, },
leadingIcon: const Icon(MdiIcons.exclamationThick), leadingIcon: const Icon(MdiIcons.exclamationThick),
child: const Text('Bangs'), child: const Text('Bangs'),
), ),
MenuItemButton(
onPressed: () async {
await context.push(FeedListRoute().location);
},
leadingIcon: const Icon(Icons.rss_feed),
child: const Text('Feeds'),
),
const Divider(), const Divider(),
if (selectedTabId != null) if (selectedTabId != null)
MenuItemButton( 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'),
),
], ],
), ),
], ],
@@ -4,7 +4,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/logger.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.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/selected_tab.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_session.dart'; import 'package:lensai/features/geckoview/domain/providers/tab_session.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart'; import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
@@ -78,6 +81,12 @@ class _BrowserViewState extends ConsumerState<BrowserView>
}, },
); );
ref.listen(feedRequestedProvider, (previous, next) async {
if (next.valueOrNull.mapNotNull(Uri.tryParse) case final Uri url) {
await FeedAddRoute($extra: url).push(context);
}
});
return Visibility( return Visibility(
visible: hasTab, visible: hasTab,
replacement: SizedBox.expand(child: Container(color: Colors.grey[800])), replacement: SizedBox.expand(child: Container(color: Colors.grey[800])),
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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; import 'package:lensai/utils/uri_parser.dart' as uri_parser;
class EditUrlDialog extends HookWidget { class EditUrlDialog extends HookWidget {
@@ -22,13 +23,7 @@ class EditUrlDialog extends HookWidget {
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
decoration: const InputDecoration(hintText: 'Enter URL'), decoration: const InputDecoration(hintText: 'Enter URL'),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { return validateUrl(value, requireAuthority: false);
return 'Please enter a URL';
}
if (uri_parser.tryParseUrl(value) == null) {
return 'Please enter a valid URL';
}
return null;
}, },
), ),
), ),
@@ -40,7 +35,9 @@ class EditUrlDialog extends HookWidget {
TextButton( TextButton(
onPressed: () { onPressed: () {
if (formKey.currentState?.validate() ?? false) { 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'), child: const Text('Edit'),
@@ -4,7 +4,6 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_reorderable_grid_view/widgets/widgets.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/providers/global_drop.dart'; import 'package:lensai/core/providers/global_drop.dart';
import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/core/routing/routes.dart';
@@ -376,7 +375,7 @@ class ViewTabsSheetWidget extends HookConsumerWidget {
), ),
child: FloatingActionButton.small( child: FloatingActionButton.small(
onPressed: () async { onPressed: () async {
await context.push(const SearchRoute().location); await const SearchRoute().push(context);
onClose(); onClose();
}, },
@@ -45,6 +45,7 @@ class ReaderButton extends HookConsumerWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 15.0, horizontal: 8.0), padding: const EdgeInsets.symmetric(vertical: 15.0, horizontal: 8.0),
child: readerChanging.when( child: readerChanging.when(
skipLoadingOnReload: true,
data: data:
(_) => Visibility( (_) => Visibility(
visible: readerabilityState.readerable, visible: readerabilityState.readerable,
@@ -86,7 +86,7 @@ class SearchScreen extends HookConsumerWidget {
ref.read(selectedBangDataProvider()) ?? ref.read(selectedBangDataProvider()) ??
await ref.read(defaultSearchBangDataProvider.future); await ref.read(defaultSearchBangDataProvider.future);
newUrl = defaultSearchBang?.getUrl(value); newUrl = defaultSearchBang?.getTemplateUrl(value);
} }
if (newUrl != null) { if (newUrl != null) {
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/core/routing/routes.dart';
import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/extensions/nullable.dart';
@@ -52,15 +51,16 @@ class BangChips extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final availableBangs = ref.watch(seamlessBangProviderProvider); final availableBangs = ref.watch(seamlessBangProvider);
useListenableCallback(searchTextController, () { useListenableCallback(searchTextController, () {
ref ref
.read(seamlessBangProviderProvider.notifier) .read(seamlessBangProvider.notifier)
.search(searchTextController!.text); .search(searchTextController!.text);
}); });
return availableBangs.when( return availableBangs.when(
skipLoadingOnReload: true,
data: (availableBangs) { data: (availableBangs) {
return SizedBox( return SizedBox(
height: 48, height: 48,
@@ -71,7 +71,8 @@ class BangChips extends HookConsumerWidget {
child: SelectableChips( child: SelectableChips(
itemId: (bang) => bang.trigger, itemId: (bang) => bang.trigger,
itemAvatar: itemAvatar:
(bang) => UrlIcon(bang.getUrl(''), iconSize: 20), (bang) =>
UrlIcon([bang.getTemplateUrl('')], iconSize: 20),
itemLabel: (bang) => Text(bang.websiteName), itemLabel: (bang) => Text(bang.websiteName),
availableItems: availableBangs, availableItems: availableBangs,
selectedItem: activeBang, selectedItem: activeBang,
@@ -97,14 +98,12 @@ class BangChips extends HookConsumerWidget {
onPressed: () async { onPressed: () async {
final searchText = searchTextController?.text.trim(); final searchText = searchTextController?.text.trim();
await context.push( await BangSearchRoute(
BangSearchRoute(
searchText: searchText:
(searchText.isEmpty) (searchText.isEmpty)
? BangSearchRoute.emptySearchText ? BangSearchRoute.emptySearchText
: searchText!, : searchText!,
).location, ).push(context);
);
}, },
icon: const Icon(Icons.chevron_right), icon: const Icon(Icons.chevron_right),
), ),
@@ -67,7 +67,9 @@ class SearchField extends HookConsumerWidget {
(showBangIcon && activeBang != null) (showBangIcon && activeBang != null)
? Padding( ? Padding(
padding: const EdgeInsetsDirectional.all(12.0), padding: const EdgeInsetsDirectional.all(12.0),
child: UrlIcon(activeBang!.getUrl(''), iconSize: 24.0), child: UrlIcon([
activeBang!.getTemplateUrl(''),
], iconSize: 24.0),
) )
: null, : null,
label: label ?? const Text('Search'), label: label ?? const Text('Search'),
@@ -48,6 +48,7 @@ class HistorySuggestions extends HookConsumerWidget {
SliverSkeletonizer( SliverSkeletonizer(
enabled: historySuggestions.isLoading, enabled: historySuggestions.isLoading,
child: historySuggestions.when( child: historySuggestions.when(
skipLoadingOnReload: true,
data: (historySuggestions) { data: (historySuggestions) {
return SliverList.builder( return SliverList.builder(
itemCount: historySuggestions.length, itemCount: historySuggestions.length,
@@ -96,13 +96,11 @@ class TabSearch extends HookConsumerWidget {
return ListTile( return ListTile(
leading: RepaintBoundary( leading: RepaintBoundary(
child: child:
(result.icon != null) result.icon.mapNotNull(
? RawImage( (icon) =>
image: result.icon?.value, RawImage(image: icon.value, height: 24, width: 24),
height: 24, ) ??
width: 24, UrlIcon([result.url], iconSize: 24),
)
: UrlIcon(result.url, iconSize: 24),
), ),
title: result.title.mapNotNull( title: result.title.mapNotNull(
(title) => MarkdownBody( (title) => MarkdownBody(
@@ -226,11 +226,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with _$TabDaoMixin {
ellipsis: ellipsis, ellipsis: ellipsis,
); );
} else { } else {
return db.queryTabsBasic( return db.queryTabsBasic(query: db.buildLikeQuery(searchString));
query: db.buildLikeQuery(searchString),
beforeMatch: matchPrefix,
afterMatch: matchSuffix,
);
} }
} }
} }
@@ -126,10 +126,10 @@ queryTabsBasic WITH TabQueryResult:
) )
SELECT SELECT
t.id, t.id,
highlight(tab_fts, 0, :beforeMatch, :afterMatch) AS title, t.title,
highlight(tab_fts, 1, :beforeMatch, :afterMatch) AS url, CAST(t.url AS TEXT) AS url,
bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank, t.url AS clean_url,
t.url AS clean_url bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank
FROM tab_fts fts FROM tab_fts fts
INNER JOIN INNER JOIN
tab t ON t.rowid = fts.rowid tab t ON t.rowid = fts.rowid
@@ -156,11 +156,11 @@ queryTabsFullContent WITH TabQueryResult:
highlight(tab_fts, 1, :beforeMatch, :afterMatch) AS url, highlight(tab_fts, 1, :beforeMatch, :afterMatch) AS url,
snippet(tab_fts, 2, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS extracted_content, snippet(tab_fts, 2, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS extracted_content,
snippet(tab_fts, 3, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS full_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, bm25(tab_fts, weights.title_weight, weights.url_weight,
weights.extracted_weight, weights.full_weight) weights.extracted_weight, weights.full_weight)
) AS weighted_rank, ) AS weighted_rank
t.url AS clean_url
FROM tab_fts(:query) fts FROM tab_fts(:query) fts
INNER JOIN INNER JOIN
tab t ON t.rowid = fts.rowid tab t ON t.rowid = fts.rowid
@@ -1901,18 +1901,10 @@ abstract class _$TabDatabase extends GeneratedDatabase {
).map((QueryRow row) => row.read<String>('_c0')); ).map((QueryRow row) => row.read<String>('_c0'));
} }
Selectable<TabQueryResult> queryTabsBasic({ Selectable<TabQueryResult> queryTabsBasic({required String query}) {
required String beforeMatch,
required String afterMatch,
required String query,
}) {
return customSelect( 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', '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: [ variables: [Variable<String>(query)],
Variable<String>(beforeMatch),
Variable<String>(afterMatch),
Variable<String>(query),
],
readsFrom: {tab, tabFts}, readsFrom: {tab, tabFts},
).map( ).map(
(QueryRow row) => TabQueryResult( (QueryRow row) => TabQueryResult(
@@ -1936,7 +1928,7 @@ abstract class _$TabDatabase extends GeneratedDatabase {
required String query, required String query,
}) { }) {
return customSelect( 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: [ variables: [
Variable<String>(beforeMatch), Variable<String>(beforeMatch),
Variable<String>(afterMatch), Variable<String>(afterMatch),
@@ -1,7 +1,6 @@
import 'package:fading_scroll/fading_scroll.dart'; import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/core/routing/routes.dart';
import 'package:lensai/core/uuid.dart'; import 'package:lensai/core/uuid.dart';
@@ -28,6 +27,7 @@ class ContainerListScreen extends HookConsumerWidget {
return Skeletonizer( return Skeletonizer(
enabled: containersAsync.isLoading, enabled: containersAsync.isLoading,
child: containersAsync.when( child: containersAsync.when(
skipLoadingOnReload: true,
data: data:
(containers) => FadingScroll( (containers) => FadingScroll(
fadingSize: 25, fadingSize: 25,
@@ -115,10 +115,9 @@ class ContainerListScreen extends HookConsumerWidget {
.unusedRandomContainerColor(); .unusedRandomContainerColor();
if (context.mounted) { if (context.mounted) {
final result = await context.push<ContainerData?>( final result = await ContainerCreateRoute(
ContainerCreateRoute().location, ContainerData(id: uuid.v7(), color: initialColor),
extra: ContainerData(id: uuid.v7(), color: initialColor), ).push<ContainerData?>(context);
);
if (result != null) { if (result != null) {
await ref await ref
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/providers/global_drop.dart'; import 'package:lensai/core/providers/global_drop.dart';
import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/core/routing/routes.dart';
@@ -201,7 +200,7 @@ class ContainerChips extends HookConsumerWidget {
if (displayMenu) if (displayMenu)
IconButton( IconButton(
onPressed: () async { onPressed: () async {
await context.push(ContainerListRoute().location); await ContainerListRoute().push(context);
}, },
icon: const Icon(Icons.chevron_right), icon: const Icon(Icons.chevron_right),
), ),
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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/core/routing/routes.dart';
import 'package:lensai/features/geckoview/features/tabs/data/models/container_data.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'), title: Text(container.name ?? 'New Container'),
trailing: IconButton( trailing: IconButton(
onPressed: () async { onPressed: () async {
await context.push(ContainerEditRoute().location, extra: container); await ContainerEditRoute(container).push(context);
}, },
icon: const Icon(Icons.chevron_right), icon: const Icon(Icons.chevron_right),
), ),
@@ -1,7 +1,6 @@
import 'package:fading_scroll/fading_scroll.dart'; import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/core/routing/routes.dart';
@@ -31,7 +30,7 @@ class SettingsScreen extends HookConsumerWidget {
leading: const Icon(Icons.settings), leading: const Icon(Icons.settings),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () async { onTap: () async {
await context.push(GeneralSettingsRoute().location); await GeneralSettingsRoute().push(context);
}, },
), ),
), ),
@@ -48,7 +47,7 @@ class SettingsScreen extends HookConsumerWidget {
leading: const Icon(MdiIcons.web), leading: const Icon(MdiIcons.web),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () async { onTap: () async {
await context.push(WebEngineSettingsRoute().location); await WebEngineSettingsRoute().push(context);
}, },
), ),
), ),
@@ -65,7 +64,7 @@ class SettingsScreen extends HookConsumerWidget {
leading: const Icon(MdiIcons.exclamationThick), leading: const Icon(MdiIcons.exclamationThick),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () async { onTap: () async {
await context.push(BangSettingsRoute().location); await BangSettingsRoute().push(context);
}, },
), ),
), ),
@@ -1,7 +1,6 @@
import 'package:fast_equatable/fast_equatable.dart'; import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/core/routing/routes.dart';
import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/extensions/nullable.dart';
@@ -33,6 +32,7 @@ class WebEngineHardeningScreen extends HookConsumerWidget {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Web Engine Hardening')), appBar: AppBar(title: const Text('Web Engine Hardening')),
body: preferenceGroups.when( body: preferenceGroups.when(
skipLoadingOnReload: true,
data: (data) { data: (data) {
return Column( return Column(
children: [ children: [
@@ -85,11 +85,9 @@ class WebEngineHardeningScreen extends HookConsumerWidget {
), ),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () async { onTap: () async {
await context.push( await WebEngineHardeningGroupRoute(
WebEngineHardeningGroupRoute(
group: group.key, group: group.key,
).location, ).push(context);
);
}, },
), ),
), ),
@@ -2,7 +2,6 @@ import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/core/routing/routes.dart';
import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/extensions/nullable.dart';
@@ -307,7 +306,7 @@ class WebEngineSettingsScreen extends HookConsumerWidget {
leading: const Icon(MdiIcons.shieldLock), leading: const Icon(MdiIcons.shieldLock),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () async { onTap: () async {
await context.push(WebEngineHardeningRoute().location); await WebEngineHardeningRoute().push(context);
}, },
), ),
], ],
@@ -10,7 +10,7 @@ class ArticleDao extends DatabaseAccessor<FeedDatabase> with _$ArticleDaoMixin {
ArticleDao(super.attachedDatabase); ArticleDao(super.attachedDatabase);
Selectable<FeedArticle> getFeedArticles(Uri? url) { Selectable<FeedArticle> getFeedArticles(Uri? url) {
final select = db.article.select(); final select = db.articleView.select();
if (url != null) { if (url != null) {
select.where((article) => article.feedId.equalsValue(url)); select.where((article) => article.feedId.equalsValue(url));
@@ -25,7 +25,7 @@ class ArticleDao extends DatabaseAccessor<FeedDatabase> with _$ArticleDaoMixin {
} }
SingleOrNullSelectable<FeedArticle> getArticleById(String articleId) { SingleOrNullSelectable<FeedArticle> getArticleById(String articleId) {
return db.article.select()..where((row) => row.id.equals(articleId)); return db.articleView.select()..where((row) => row.id.equals(articleId));
} }
Future<void> upsertArticles(List<FeedArticle> articles) { Future<void> upsertArticles(List<FeedArticle> articles) {
@@ -111,8 +111,6 @@ class ArticleDao extends DatabaseAccessor<FeedDatabase> with _$ArticleDaoMixin {
return db.queryArticlesBasic( return db.queryArticlesBasic(
feedId: feedId?.toString(), feedId: feedId?.toString(),
query: db.buildLikeQuery(searchString), query: db.buildLikeQuery(searchString),
beforeMatch: matchPrefix,
afterMatch: matchSuffix,
); );
} }
} }
@@ -11,15 +11,19 @@ class FeedDao extends DatabaseAccessor<FeedDatabase> with _$FeedDaoMixin {
return db.feed.select(); return db.feed.select();
} }
Future<int> updateFeedFetched(Uri url, DateTime fetched) { SingleOrNullSelectable<FeedData> getFeed(Uri feedId) {
return db.feed.select()..where((feed) => feed.url.equalsValue(feedId));
}
Future<int> updateFeedFetched(Uri feedId, DateTime fetched) {
final statement = 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))); return statement.write(FeedCompanion(lastFetched: Value(fetched)));
} }
Future<int> deleteFeed(Uri url) { Future<int> deleteFeed(Uri feedId) {
return db.feed.deleteWhere((feed) => feed.url.equals(url.toString())); return db.feed.deleteWhere((feed) => feed.url.equals(feedId.toString()));
} }
Future<int> upsertFeed(FeedData feedData) { Future<int> upsertFeed(FeedData feedData) {
@@ -9,6 +9,8 @@ CREATE TABLE feed (
url TEXT PRIMARY KEY NOT NULL MAPPED BY `const UriConverter()`, url TEXT PRIMARY KEY NOT NULL MAPPED BY `const UriConverter()`,
title TEXT, title TEXT,
description TEXT, description TEXT,
icon TEXT MAPPED BY `const UriConverter()`,
site_link TEXT MAPPED BY `const UriConverter()`,
authors TEXT MAPPED BY `const FeedAuthorsConverter()`, authors TEXT MAPPED BY `const FeedAuthorsConverter()`,
tags TEXT MAPPED BY `const FeedCategoriesConverter()`, tags TEXT MAPPED BY `const FeedCategoriesConverter()`,
last_fetched DATETIME last_fetched DATETIME
@@ -31,6 +33,15 @@ CREATE TABLE article (
contentPlain TEXT contentPlain TEXT
) WITH FeedArticle; ) 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 INDEX article_feed_id ON article (feed_id);
CREATE VIRTUAL TABLE article_fts CREATE VIRTUAL TABLE article_fts
@@ -73,17 +84,19 @@ queryArticlesBasic(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult:
) )
SELECT SELECT
a.*, a.*,
highlight(article_fts, 0, :beforeMatch, :afterMatch) AS title, f.icon,
( (
bm25(article_fts, weights.title_weight) bm25(article_fts, weights.title_weight)
) AS weighted_rank ) AS weighted_rank
FROM article_fts(:query) fts FROM article_fts fts
INNER JOIN INNER JOIN
article a ON a.rowid = fts.rowid article a ON a.rowid = fts.rowid
INNER JOIN
feed f ON f.url = a.feed_id
CROSS JOIN weights CROSS JOIN weights
WHERE WHERE
fts.title LIKE :query AND 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 ORDER BY
weighted_rank ASC, weighted_rank ASC,
a.created DESC NULLS LAST; a.created DESC NULLS LAST;
@@ -92,15 +105,16 @@ queryArticlesFullContent(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult:
WITH weights AS ( WITH weights AS (
SELECT SELECT
-- Customize these weights (higher = more important) -- Customize these weights (higher = more important)
5.0 as title_weight, -- Title matches are most important 10.0 as title_weight, -- Title matches are most important
2.0 as summary_weight, -- Summary matches are quite important 3.0 as summary_weight, -- Summary matches are quite important
1.0 as content_weight -- Content matches are basic 1.0 as content_weight -- Content matches are basic
) )
SELECT SELECT
a.*, a.*,
highlight(article_fts, 0, :beforeMatch, :afterMatch) AS title, f.icon,
snippet(article_fts, 1, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS summary, highlight(article_fts, 0, :beforeMatch, :afterMatch) AS title_highlight,
snippet(article_fts, 2, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS content, 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, bm25(article_fts, weights.title_weight, weights.summary_weight,
weights.content_weight) weights.content_weight)
@@ -108,6 +122,8 @@ queryArticlesFullContent(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult:
FROM article_fts(:query) fts FROM article_fts(:query) fts
INNER JOIN INNER JOIN
article a ON a.rowid = fts.rowid article a ON a.rowid = fts.rowid
INNER JOIN
feed f ON f.url = a.feed_id
CROSS JOIN weights CROSS JOIN weights
WHERE WHERE
:feed_id IS NULL OR a.feed_id = :feed_id :feed_id IS NULL OR a.feed_id = :feed_id
@@ -33,6 +33,24 @@ class Feed extends Table with TableInfo<Feed, FeedData> {
requiredDuringInsert: false, requiredDuringInsert: false,
$customConstraints: '', $customConstraints: '',
); );
late final GeneratedColumnWithTypeConverter<Uri?, String> icon =
GeneratedColumn<String>(
'icon',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
$customConstraints: '',
).withConverter<Uri?>(Feed.$convertericonn);
late final GeneratedColumnWithTypeConverter<Uri?, String> siteLink =
GeneratedColumn<String>(
'site_link',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
$customConstraints: '',
).withConverter<Uri?>(Feed.$convertersiteLinkn);
late final GeneratedColumnWithTypeConverter<List<FeedAuthor>?, String> late final GeneratedColumnWithTypeConverter<List<FeedAuthor>?, String>
authors = GeneratedColumn<String>( authors = GeneratedColumn<String>(
'authors', 'authors',
@@ -64,6 +82,8 @@ class Feed extends Table with TableInfo<Feed, FeedData> {
url, url,
title, title,
description, description,
icon,
siteLink,
authors, authors,
tags, tags,
lastFetched, lastFetched,
@@ -93,6 +113,18 @@ class Feed extends Table with TableInfo<Feed, FeedData> {
DriftSqlType.string, DriftSqlType.string,
data['${effectivePrefix}description'], 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( authors: Feed.$converterauthorsn.fromSql(
attachedDatabase.typeMapping.read( attachedDatabase.typeMapping.read(
DriftSqlType.string, DriftSqlType.string,
@@ -118,6 +150,12 @@ class Feed extends Table with TableInfo<Feed, FeedData> {
} }
static TypeConverter<Uri, String> $converterurl = const UriConverter(); static TypeConverter<Uri, String> $converterurl = const UriConverter();
static TypeConverter<Uri, String> $convertericon = const UriConverter();
static TypeConverter<Uri?, String?> $convertericonn =
NullAwareTypeConverter.wrap($convertericon);
static TypeConverter<Uri, String> $convertersiteLink = const UriConverter();
static TypeConverter<Uri?, String?> $convertersiteLinkn =
NullAwareTypeConverter.wrap($convertersiteLink);
static TypeConverter<List<FeedAuthor>, String> $converterauthors = static TypeConverter<List<FeedAuthor>, String> $converterauthors =
const FeedAuthorsConverter(); const FeedAuthorsConverter();
static TypeConverter<List<FeedAuthor>?, String?> $converterauthorsn = static TypeConverter<List<FeedAuthor>?, String?> $converterauthorsn =
@@ -134,6 +172,8 @@ class FeedData extends DataClass implements Insertable<FeedData> {
final Uri url; final Uri url;
final String? title; final String? title;
final String? description; final String? description;
final Uri? icon;
final Uri? siteLink;
final List<FeedAuthor>? authors; final List<FeedAuthor>? authors;
final List<FeedCategory>? tags; final List<FeedCategory>? tags;
final DateTime? lastFetched; final DateTime? lastFetched;
@@ -141,6 +181,8 @@ class FeedData extends DataClass implements Insertable<FeedData> {
required this.url, required this.url,
this.title, this.title,
this.description, this.description,
this.icon,
this.siteLink,
this.authors, this.authors,
this.tags, this.tags,
this.lastFetched, this.lastFetched,
@@ -157,6 +199,14 @@ class FeedData extends DataClass implements Insertable<FeedData> {
if (!nullToAbsent || description != null) { if (!nullToAbsent || description != null) {
map['description'] = Variable<String>(description); map['description'] = Variable<String>(description);
} }
if (!nullToAbsent || icon != null) {
map['icon'] = Variable<String>(Feed.$convertericonn.toSql(icon));
}
if (!nullToAbsent || siteLink != null) {
map['site_link'] = Variable<String>(
Feed.$convertersiteLinkn.toSql(siteLink),
);
}
if (!nullToAbsent || authors != null) { if (!nullToAbsent || authors != null) {
map['authors'] = Variable<String>(Feed.$converterauthorsn.toSql(authors)); map['authors'] = Variable<String>(Feed.$converterauthorsn.toSql(authors));
} }
@@ -178,6 +228,8 @@ class FeedData extends DataClass implements Insertable<FeedData> {
url: serializer.fromJson<Uri>(json['url']), url: serializer.fromJson<Uri>(json['url']),
title: serializer.fromJson<String?>(json['title']), title: serializer.fromJson<String?>(json['title']),
description: serializer.fromJson<String?>(json['description']), description: serializer.fromJson<String?>(json['description']),
icon: serializer.fromJson<Uri?>(json['icon']),
siteLink: serializer.fromJson<Uri?>(json['site_link']),
authors: serializer.fromJson<List<FeedAuthor>?>(json['authors']), authors: serializer.fromJson<List<FeedAuthor>?>(json['authors']),
tags: serializer.fromJson<List<FeedCategory>?>(json['tags']), tags: serializer.fromJson<List<FeedCategory>?>(json['tags']),
lastFetched: serializer.fromJson<DateTime?>(json['last_fetched']), lastFetched: serializer.fromJson<DateTime?>(json['last_fetched']),
@@ -190,6 +242,8 @@ class FeedData extends DataClass implements Insertable<FeedData> {
'url': serializer.toJson<Uri>(url), 'url': serializer.toJson<Uri>(url),
'title': serializer.toJson<String?>(title), 'title': serializer.toJson<String?>(title),
'description': serializer.toJson<String?>(description), 'description': serializer.toJson<String?>(description),
'icon': serializer.toJson<Uri?>(icon),
'site_link': serializer.toJson<Uri?>(siteLink),
'authors': serializer.toJson<List<FeedAuthor>?>(authors), 'authors': serializer.toJson<List<FeedAuthor>?>(authors),
'tags': serializer.toJson<List<FeedCategory>?>(tags), 'tags': serializer.toJson<List<FeedCategory>?>(tags),
'last_fetched': serializer.toJson<DateTime?>(lastFetched), 'last_fetched': serializer.toJson<DateTime?>(lastFetched),
@@ -200,6 +254,8 @@ class FeedData extends DataClass implements Insertable<FeedData> {
Uri? url, Uri? url,
Value<String?> title = const Value.absent(), Value<String?> title = const Value.absent(),
Value<String?> description = const Value.absent(), Value<String?> description = const Value.absent(),
Value<Uri?> icon = const Value.absent(),
Value<Uri?> siteLink = const Value.absent(),
Value<List<FeedAuthor>?> authors = const Value.absent(), Value<List<FeedAuthor>?> authors = const Value.absent(),
Value<List<FeedCategory>?> tags = const Value.absent(), Value<List<FeedCategory>?> tags = const Value.absent(),
Value<DateTime?> lastFetched = const Value.absent(), Value<DateTime?> lastFetched = const Value.absent(),
@@ -207,6 +263,8 @@ class FeedData extends DataClass implements Insertable<FeedData> {
url: url ?? this.url, url: url ?? this.url,
title: title.present ? title.value : this.title, title: title.present ? title.value : this.title,
description: description.present ? description.value : this.description, 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, authors: authors.present ? authors.value : this.authors,
tags: tags.present ? tags.value : this.tags, tags: tags.present ? tags.value : this.tags,
lastFetched: lastFetched.present ? lastFetched.value : this.lastFetched, lastFetched: lastFetched.present ? lastFetched.value : this.lastFetched,
@@ -217,6 +275,8 @@ class FeedData extends DataClass implements Insertable<FeedData> {
title: data.title.present ? data.title.value : this.title, title: data.title.present ? data.title.value : this.title,
description: description:
data.description.present ? data.description.value : this.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, authors: data.authors.present ? data.authors.value : this.authors,
tags: data.tags.present ? data.tags.value : this.tags, tags: data.tags.present ? data.tags.value : this.tags,
lastFetched: lastFetched:
@@ -230,6 +290,8 @@ class FeedData extends DataClass implements Insertable<FeedData> {
..write('url: $url, ') ..write('url: $url, ')
..write('title: $title, ') ..write('title: $title, ')
..write('description: $description, ') ..write('description: $description, ')
..write('icon: $icon, ')
..write('siteLink: $siteLink, ')
..write('authors: $authors, ') ..write('authors: $authors, ')
..write('tags: $tags, ') ..write('tags: $tags, ')
..write('lastFetched: $lastFetched') ..write('lastFetched: $lastFetched')
@@ -238,8 +300,16 @@ class FeedData extends DataClass implements Insertable<FeedData> {
} }
@override @override
int get hashCode => int get hashCode => Object.hash(
Object.hash(url, title, description, authors, tags, lastFetched); url,
title,
description,
icon,
siteLink,
authors,
tags,
lastFetched,
);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
@@ -247,6 +317,8 @@ class FeedData extends DataClass implements Insertable<FeedData> {
other.url == this.url && other.url == this.url &&
other.title == this.title && other.title == this.title &&
other.description == this.description && other.description == this.description &&
other.icon == this.icon &&
other.siteLink == this.siteLink &&
other.authors == this.authors && other.authors == this.authors &&
other.tags == this.tags && other.tags == this.tags &&
other.lastFetched == this.lastFetched); other.lastFetched == this.lastFetched);
@@ -256,6 +328,8 @@ class FeedCompanion extends UpdateCompanion<FeedData> {
final Value<Uri> url; final Value<Uri> url;
final Value<String?> title; final Value<String?> title;
final Value<String?> description; final Value<String?> description;
final Value<Uri?> icon;
final Value<Uri?> siteLink;
final Value<List<FeedAuthor>?> authors; final Value<List<FeedAuthor>?> authors;
final Value<List<FeedCategory>?> tags; final Value<List<FeedCategory>?> tags;
final Value<DateTime?> lastFetched; final Value<DateTime?> lastFetched;
@@ -264,6 +338,8 @@ class FeedCompanion extends UpdateCompanion<FeedData> {
this.url = const Value.absent(), this.url = const Value.absent(),
this.title = const Value.absent(), this.title = const Value.absent(),
this.description = const Value.absent(), this.description = const Value.absent(),
this.icon = const Value.absent(),
this.siteLink = const Value.absent(),
this.authors = const Value.absent(), this.authors = const Value.absent(),
this.tags = const Value.absent(), this.tags = const Value.absent(),
this.lastFetched = const Value.absent(), this.lastFetched = const Value.absent(),
@@ -273,6 +349,8 @@ class FeedCompanion extends UpdateCompanion<FeedData> {
required Uri url, required Uri url,
this.title = const Value.absent(), this.title = const Value.absent(),
this.description = const Value.absent(), this.description = const Value.absent(),
this.icon = const Value.absent(),
this.siteLink = const Value.absent(),
this.authors = const Value.absent(), this.authors = const Value.absent(),
this.tags = const Value.absent(), this.tags = const Value.absent(),
this.lastFetched = const Value.absent(), this.lastFetched = const Value.absent(),
@@ -282,6 +360,8 @@ class FeedCompanion extends UpdateCompanion<FeedData> {
Expression<String>? url, Expression<String>? url,
Expression<String>? title, Expression<String>? title,
Expression<String>? description, Expression<String>? description,
Expression<String>? icon,
Expression<String>? siteLink,
Expression<String>? authors, Expression<String>? authors,
Expression<String>? tags, Expression<String>? tags,
Expression<DateTime>? lastFetched, Expression<DateTime>? lastFetched,
@@ -291,6 +371,8 @@ class FeedCompanion extends UpdateCompanion<FeedData> {
if (url != null) 'url': url, if (url != null) 'url': url,
if (title != null) 'title': title, if (title != null) 'title': title,
if (description != null) 'description': description, if (description != null) 'description': description,
if (icon != null) 'icon': icon,
if (siteLink != null) 'site_link': siteLink,
if (authors != null) 'authors': authors, if (authors != null) 'authors': authors,
if (tags != null) 'tags': tags, if (tags != null) 'tags': tags,
if (lastFetched != null) 'last_fetched': lastFetched, if (lastFetched != null) 'last_fetched': lastFetched,
@@ -302,6 +384,8 @@ class FeedCompanion extends UpdateCompanion<FeedData> {
Value<Uri>? url, Value<Uri>? url,
Value<String?>? title, Value<String?>? title,
Value<String?>? description, Value<String?>? description,
Value<Uri?>? icon,
Value<Uri?>? siteLink,
Value<List<FeedAuthor>?>? authors, Value<List<FeedAuthor>?>? authors,
Value<List<FeedCategory>?>? tags, Value<List<FeedCategory>?>? tags,
Value<DateTime?>? lastFetched, Value<DateTime?>? lastFetched,
@@ -311,6 +395,8 @@ class FeedCompanion extends UpdateCompanion<FeedData> {
url: url ?? this.url, url: url ?? this.url,
title: title ?? this.title, title: title ?? this.title,
description: description ?? this.description, description: description ?? this.description,
icon: icon ?? this.icon,
siteLink: siteLink ?? this.siteLink,
authors: authors ?? this.authors, authors: authors ?? this.authors,
tags: tags ?? this.tags, tags: tags ?? this.tags,
lastFetched: lastFetched ?? this.lastFetched, lastFetched: lastFetched ?? this.lastFetched,
@@ -330,6 +416,14 @@ class FeedCompanion extends UpdateCompanion<FeedData> {
if (description.present) { if (description.present) {
map['description'] = Variable<String>(description.value); map['description'] = Variable<String>(description.value);
} }
if (icon.present) {
map['icon'] = Variable<String>(Feed.$convertericonn.toSql(icon.value));
}
if (siteLink.present) {
map['site_link'] = Variable<String>(
Feed.$convertersiteLinkn.toSql(siteLink.value),
);
}
if (authors.present) { if (authors.present) {
map['authors'] = Variable<String>( map['authors'] = Variable<String>(
Feed.$converterauthorsn.toSql(authors.value), Feed.$converterauthorsn.toSql(authors.value),
@@ -353,6 +447,8 @@ class FeedCompanion extends UpdateCompanion<FeedData> {
..write('url: $url, ') ..write('url: $url, ')
..write('title: $title, ') ..write('title: $title, ')
..write('description: $description, ') ..write('description: $description, ')
..write('icon: $icon, ')
..write('siteLink: $siteLink, ')
..write('authors: $authors, ') ..write('authors: $authors, ')
..write('tags: $tags, ') ..write('tags: $tags, ')
..write('lastFetched: $lastFetched, ') ..write('lastFetched: $lastFetched, ')
@@ -806,6 +902,226 @@ class ArticleCompanion extends UpdateCompanion<FeedArticle> {
} }
} }
class ArticleView extends ViewInfo<ArticleView, FeedArticle>
implements HasResultSet {
final String? _alias;
@override
final _$FeedDatabase attachedDatabase;
ArticleView(this.attachedDatabase, [this._alias]);
@override
List<GeneratedColumn> 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<SqlDialect, String> 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<String, dynamic> 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<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
);
late final GeneratedColumnWithTypeConverter<Uri, String> feedId =
GeneratedColumn<String>(
'feed_id',
aliasedName,
false,
type: DriftSqlType.string,
).withConverter<Uri>(Article.$converterfeedId);
late final GeneratedColumn<DateTime> fetched = GeneratedColumn<DateTime>(
'fetched',
aliasedName,
false,
type: DriftSqlType.dateTime,
);
late final GeneratedColumn<DateTime> created = GeneratedColumn<DateTime>(
'created',
aliasedName,
true,
type: DriftSqlType.dateTime,
);
late final GeneratedColumn<DateTime> updated = GeneratedColumn<DateTime>(
'updated',
aliasedName,
true,
type: DriftSqlType.dateTime,
);
late final GeneratedColumn<DateTime> lastRead = GeneratedColumn<DateTime>(
'last_read',
aliasedName,
true,
type: DriftSqlType.dateTime,
);
late final GeneratedColumn<String> title = GeneratedColumn<String>(
'title',
aliasedName,
true,
type: DriftSqlType.string,
);
late final GeneratedColumnWithTypeConverter<List<FeedAuthor>?, String>
authors = GeneratedColumn<String>(
'authors',
aliasedName,
true,
type: DriftSqlType.string,
).withConverter<List<FeedAuthor>?>(Article.$converterauthorsn);
late final GeneratedColumnWithTypeConverter<List<FeedCategory>?, String>
tags = GeneratedColumn<String>(
'tags',
aliasedName,
true,
type: DriftSqlType.string,
).withConverter<List<FeedCategory>?>(Article.$convertertagsn);
late final GeneratedColumnWithTypeConverter<List<FeedLink>?, String> links =
GeneratedColumn<String>(
'links',
aliasedName,
true,
type: DriftSqlType.string,
).withConverter<List<FeedLink>?>(Article.$converterlinksn);
late final GeneratedColumn<String> summaryMarkdown = GeneratedColumn<String>(
'summaryMarkdown',
aliasedName,
true,
type: DriftSqlType.string,
);
late final GeneratedColumn<String> summaryPlain = GeneratedColumn<String>(
'summaryPlain',
aliasedName,
true,
type: DriftSqlType.string,
);
late final GeneratedColumn<String> contentMarkdown = GeneratedColumn<String>(
'contentMarkdown',
aliasedName,
true,
type: DriftSqlType.string,
);
late final GeneratedColumn<String> contentPlain = GeneratedColumn<String>(
'contentPlain',
aliasedName,
true,
type: DriftSqlType.string,
);
late final GeneratedColumnWithTypeConverter<Uri?, String> icon =
GeneratedColumn<String>(
'icon',
aliasedName,
true,
type: DriftSqlType.string,
).withConverter<Uri?>(Feed.$convertericonn);
@override
ArticleView createAlias(String alias) {
return ArticleView(attachedDatabase, alias);
}
@override
Query? get query => null;
@override
Set<String> get readTables => const {'article', 'feed'};
}
class ArticleFts extends Table class ArticleFts extends Table
with with
TableInfo<ArticleFts, ArticleFt>, TableInfo<ArticleFts, ArticleFt>,
@@ -1046,6 +1362,7 @@ abstract class _$FeedDatabase extends GeneratedDatabase {
$FeedDatabaseManager get managers => $FeedDatabaseManager(this); $FeedDatabaseManager get managers => $FeedDatabaseManager(this);
late final Feed feed = Feed(this); late final Feed feed = Feed(this);
late final Article article = Article(this); late final Article article = Article(this);
late final ArticleView articleView = ArticleView(this);
late final Index articleFeedId = Index( late final Index articleFeedId = Index(
'article_feed_id', 'article_feed_id',
'CREATE INDEX article_feed_id ON article (feed_id)', 'CREATE INDEX article_feed_id ON article (feed_id)',
@@ -1074,20 +1391,13 @@ abstract class _$FeedDatabase extends GeneratedDatabase {
} }
Selectable<FeedArticleQueryResult> queryArticlesBasic({ Selectable<FeedArticleQueryResult> queryArticlesBasic({
required String beforeMatch,
required String afterMatch,
required String query, required String query,
String? feedId, String? feedId,
}) { }) {
return customSelect( 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', '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: [ variables: [Variable<String>(query), Variable<String>(feedId)],
Variable<String>(beforeMatch), readsFrom: {feed, articleFts, article},
Variable<String>(afterMatch),
Variable<String>(query),
Variable<String>(feedId),
],
readsFrom: {articleFts, article},
).map( ).map(
(QueryRow row) => FeedArticleQueryResult( (QueryRow row) => FeedArticleQueryResult(
id: row.read<String>('id'), id: row.read<String>('id'),
@@ -1114,6 +1424,10 @@ abstract class _$FeedDatabase extends GeneratedDatabase {
summaryPlain: row.readNullable<String>('summaryPlain'), summaryPlain: row.readNullable<String>('summaryPlain'),
contentMarkdown: row.readNullable<String>('contentMarkdown'), contentMarkdown: row.readNullable<String>('contentMarkdown'),
contentPlain: row.readNullable<String>('contentPlain'), contentPlain: row.readNullable<String>('contentPlain'),
icon: NullAwareTypeConverter.wrapFromSql(
Feed.$convertericon,
row.readNullable<String>('icon'),
),
), ),
); );
} }
@@ -1127,7 +1441,7 @@ abstract class _$FeedDatabase extends GeneratedDatabase {
String? feedId, String? feedId,
}) { }) {
return customSelect( 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: [ variables: [
Variable<String>(beforeMatch), Variable<String>(beforeMatch),
Variable<String>(afterMatch), Variable<String>(afterMatch),
@@ -1136,7 +1450,7 @@ abstract class _$FeedDatabase extends GeneratedDatabase {
Variable<String>(query), Variable<String>(query),
Variable<String>(feedId), Variable<String>(feedId),
], ],
readsFrom: {articleFts, article}, readsFrom: {feed, articleFts, article},
).map( ).map(
(QueryRow row) => FeedArticleQueryResult( (QueryRow row) => FeedArticleQueryResult(
id: row.read<String>('id'), id: row.read<String>('id'),
@@ -1163,6 +1477,13 @@ abstract class _$FeedDatabase extends GeneratedDatabase {
summaryPlain: row.readNullable<String>('summaryPlain'), summaryPlain: row.readNullable<String>('summaryPlain'),
contentMarkdown: row.readNullable<String>('contentMarkdown'), contentMarkdown: row.readNullable<String>('contentMarkdown'),
contentPlain: row.readNullable<String>('contentPlain'), contentPlain: row.readNullable<String>('contentPlain'),
icon: NullAwareTypeConverter.wrapFromSql(
Feed.$convertericon,
row.readNullable<String>('icon'),
),
titleHighlight: row.readNullable<String>('title_highlight'),
summarySnippet: row.readNullable<String>('summary_snippet'),
contentSnippet: row.readNullable<String>('content_snippet'),
), ),
); );
} }
@@ -1174,6 +1495,7 @@ abstract class _$FeedDatabase extends GeneratedDatabase {
List<DatabaseSchemaEntity> get allSchemaEntities => [ List<DatabaseSchemaEntity> get allSchemaEntities => [
feed, feed,
article, article,
articleView,
articleFeedId, articleFeedId,
articleFts, articleFts,
articleAfterInsert, articleAfterInsert,
@@ -1218,6 +1540,8 @@ typedef $FeedCreateCompanionBuilder =
required Uri url, required Uri url,
Value<String?> title, Value<String?> title,
Value<String?> description, Value<String?> description,
Value<Uri?> icon,
Value<Uri?> siteLink,
Value<List<FeedAuthor>?> authors, Value<List<FeedAuthor>?> authors,
Value<List<FeedCategory>?> tags, Value<List<FeedCategory>?> tags,
Value<DateTime?> lastFetched, Value<DateTime?> lastFetched,
@@ -1228,6 +1552,8 @@ typedef $FeedUpdateCompanionBuilder =
Value<Uri> url, Value<Uri> url,
Value<String?> title, Value<String?> title,
Value<String?> description, Value<String?> description,
Value<Uri?> icon,
Value<Uri?> siteLink,
Value<List<FeedAuthor>?> authors, Value<List<FeedAuthor>?> authors,
Value<List<FeedCategory>?> tags, Value<List<FeedCategory>?> tags,
Value<DateTime?> lastFetched, Value<DateTime?> lastFetched,
@@ -1282,6 +1608,18 @@ class $FeedFilterComposer extends Composer<_$FeedDatabase, Feed> {
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
); );
ColumnWithTypeConverterFilters<Uri?, Uri, String> get icon =>
$composableBuilder(
column: $table.icon,
builder: (column) => ColumnWithTypeConverterFilters(column),
);
ColumnWithTypeConverterFilters<Uri?, Uri, String> get siteLink =>
$composableBuilder(
column: $table.siteLink,
builder: (column) => ColumnWithTypeConverterFilters(column),
);
ColumnWithTypeConverterFilters<List<FeedAuthor>?, List<FeedAuthor>, String> ColumnWithTypeConverterFilters<List<FeedAuthor>?, List<FeedAuthor>, String>
get authors => $composableBuilder( get authors => $composableBuilder(
column: $table.authors, column: $table.authors,
@@ -1352,6 +1690,16 @@ class $FeedOrderingComposer extends Composer<_$FeedDatabase, Feed> {
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
); );
ColumnOrderings<String> get icon => $composableBuilder(
column: $table.icon,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get siteLink => $composableBuilder(
column: $table.siteLink,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get authors => $composableBuilder( ColumnOrderings<String> get authors => $composableBuilder(
column: $table.authors, column: $table.authors,
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
@@ -1387,6 +1735,12 @@ class $FeedAnnotationComposer extends Composer<_$FeedDatabase, Feed> {
builder: (column) => column, builder: (column) => column,
); );
GeneratedColumnWithTypeConverter<Uri?, String> get icon =>
$composableBuilder(column: $table.icon, builder: (column) => column);
GeneratedColumnWithTypeConverter<Uri?, String> get siteLink =>
$composableBuilder(column: $table.siteLink, builder: (column) => column);
GeneratedColumnWithTypeConverter<List<FeedAuthor>?, String> get authors => GeneratedColumnWithTypeConverter<List<FeedAuthor>?, String> get authors =>
$composableBuilder(column: $table.authors, builder: (column) => column); $composableBuilder(column: $table.authors, builder: (column) => column);
@@ -1455,6 +1809,8 @@ class $FeedTableManager
Value<Uri> url = const Value.absent(), Value<Uri> url = const Value.absent(),
Value<String?> title = const Value.absent(), Value<String?> title = const Value.absent(),
Value<String?> description = const Value.absent(), Value<String?> description = const Value.absent(),
Value<Uri?> icon = const Value.absent(),
Value<Uri?> siteLink = const Value.absent(),
Value<List<FeedAuthor>?> authors = const Value.absent(), Value<List<FeedAuthor>?> authors = const Value.absent(),
Value<List<FeedCategory>?> tags = const Value.absent(), Value<List<FeedCategory>?> tags = const Value.absent(),
Value<DateTime?> lastFetched = const Value.absent(), Value<DateTime?> lastFetched = const Value.absent(),
@@ -1463,6 +1819,8 @@ class $FeedTableManager
url: url, url: url,
title: title, title: title,
description: description, description: description,
icon: icon,
siteLink: siteLink,
authors: authors, authors: authors,
tags: tags, tags: tags,
lastFetched: lastFetched, lastFetched: lastFetched,
@@ -1473,6 +1831,8 @@ class $FeedTableManager
required Uri url, required Uri url,
Value<String?> title = const Value.absent(), Value<String?> title = const Value.absent(),
Value<String?> description = const Value.absent(), Value<String?> description = const Value.absent(),
Value<Uri?> icon = const Value.absent(),
Value<Uri?> siteLink = const Value.absent(),
Value<List<FeedAuthor>?> authors = const Value.absent(), Value<List<FeedAuthor>?> authors = const Value.absent(),
Value<List<FeedCategory>?> tags = const Value.absent(), Value<List<FeedCategory>?> tags = const Value.absent(),
Value<DateTime?> lastFetched = const Value.absent(), Value<DateTime?> lastFetched = const Value.absent(),
@@ -1481,6 +1841,8 @@ class $FeedTableManager
url: url, url: url,
title: title, title: title,
description: description, description: description,
icon: icon,
siteLink: siteLink,
authors: authors, authors: authors,
tags: tags, tags: tags,
lastFetched: lastFetched, lastFetched: lastFetched,
@@ -25,6 +25,9 @@ class FeedArticle with FastEquatable implements Insertable<FeedArticle> {
final String? contentMarkdown; final String? contentMarkdown;
final String? contentPlain; final String? contentPlain;
//Derived by view from feed table, should not get inserted
final Uri? icon;
FeedArticle({ FeedArticle({
required this.id, required this.id,
required this.feedId, required this.feedId,
@@ -40,6 +43,7 @@ class FeedArticle with FastEquatable implements Insertable<FeedArticle> {
this.summaryPlain, this.summaryPlain,
this.contentMarkdown, this.contentMarkdown,
this.contentPlain, this.contentPlain,
this.icon,
}); });
factory FeedArticle.fromJson(Map<String, dynamic> json) => factory FeedArticle.fromJson(Map<String, dynamic> json) =>
@@ -109,5 +113,6 @@ class FeedArticle with FastEquatable implements Insertable<FeedArticle> {
summaryPlain, summaryPlain,
contentMarkdown, contentMarkdown,
contentPlain, contentPlain,
icon,
]; ];
} }
@@ -39,6 +39,7 @@ FeedArticle _$FeedArticleFromJson(Map<String, dynamic> json) => FeedArticle(
summaryPlain: json['summaryPlain'] as String?, summaryPlain: json['summaryPlain'] as String?,
contentMarkdown: json['contentMarkdown'] as String?, contentMarkdown: json['contentMarkdown'] as String?,
contentPlain: json['contentPlain'] as String?, contentPlain: json['contentPlain'] as String?,
icon: json['icon'] == null ? null : Uri.parse(json['icon'] as String),
); );
Map<String, dynamic> _$FeedArticleToJson(FeedArticle instance) => Map<String, dynamic> _$FeedArticleToJson(FeedArticle instance) =>
@@ -57,4 +58,5 @@ Map<String, dynamic> _$FeedArticleToJson(FeedArticle instance) =>
'summaryPlain': instance.summaryPlain, 'summaryPlain': instance.summaryPlain,
'contentMarkdown': instance.contentMarkdown, 'contentMarkdown': instance.contentMarkdown,
'contentPlain': instance.contentPlain, 'contentPlain': instance.contentPlain,
'icon': instance.icon?.toString(),
}; };
@@ -1,6 +1,10 @@
import 'package:lensai/features/web_feed/data/models/feed_article.dart'; import 'package:lensai/features/web_feed/data/models/feed_article.dart';
class FeedArticleQueryResult extends FeedArticle { class FeedArticleQueryResult extends FeedArticle {
final String? titleHighlight;
final String? summarySnippet;
final String? contentSnippet;
final double weightedRank; final double weightedRank;
FeedArticleQueryResult({ FeedArticleQueryResult({
@@ -8,19 +12,29 @@ class FeedArticleQueryResult extends FeedArticle {
required super.feedId, required super.feedId,
required super.fetched, required super.fetched,
required this.weightedRank, required this.weightedRank,
super.created, required super.created,
super.updated, required super.updated,
super.lastRead, required super.lastRead,
super.title, required super.title,
super.authors, required super.authors,
super.tags, required super.tags,
super.links, required super.links,
super.summaryMarkdown, required super.summaryMarkdown,
super.summaryPlain, required super.summaryPlain,
super.contentMarkdown, required super.contentMarkdown,
super.contentPlain, required super.contentPlain,
required super.icon,
this.titleHighlight,
this.summarySnippet,
this.contentSnippet,
}); });
@override @override
List<Object?> get hashParameters => [...super.hashParameters, weightedRank]; List<Object?> get hashParameters => [
...super.hashParameters,
weightedRank,
summarySnippet,
contentSnippet,
titleHighlight,
];
} }
@@ -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<String>? tags;
FeedFilter({this.feedId, this.query, this.tags});
@override
List<Object?> get hashParameters => [feedId, query, tags];
}
@@ -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<String>? 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<String>? 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<String>? 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<String>?,
);
}
}
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);
}
+134 -7
View File
@@ -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/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_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/repositories/feed_repository.dart';
import 'package:lensai/features/web_feed/domain/services/feed_reader.dart';
import 'package:riverpod/riverpod.dart'; import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart'; part 'providers.g.dart';
@Riverpod()
class ArticleSearch extends _$ArticleSearch {
late StreamController<List<FeedArticle>> _streamController;
Future<void> 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<List<FeedArticle>> build(Uri? feedId) {
_streamController = StreamController();
ref.onDispose(() async {
await _streamController.close();
});
return _streamController.stream;
}
}
@Riverpod() @Riverpod()
Stream<List<FeedData>> feedList(Ref ref) { Stream<List<FeedData>> feedList(Ref ref) {
final repository = ref.watch(feedRepositoryProvider.notifier); final repository = ref.watch(feedRepositoryProvider.notifier);
@@ -14,25 +64,102 @@ Stream<List<FeedData>> feedList(Ref ref) {
} }
@Riverpod() @Riverpod()
Stream<List<FeedArticle>> feedArticleList(Ref ref, FeedFilter filter) { Stream<FeedData?> feedData(Ref ref, Uri? feedId) {
final repository = ref.watch(feedRepositoryProvider.notifier); final repository = ref.watch(feedRepositoryProvider.notifier);
return repository.watchFeedArticles(filter);
if (feedId == null) {
return Stream.value(null);
}
return repository.watchFeed(feedId);
} }
@Riverpod() @Riverpod()
Stream<FeedArticle?> feedArticle(Ref ref, String articleId) { Stream<List<FeedArticle>> feedArticleList(Ref ref, Uri? feedId) {
final repository = ref.watch(feedRepositoryProvider.notifier); final repository = ref.watch(feedRepositoryProvider.notifier);
return repository.watchArticle(articleId); return repository.watchFeedArticles(feedId);
} }
@Riverpod() @Riverpod()
Raw<Stream<Map<String, int>>> _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<List<FeedArticle>> 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?> 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<Stream<Map<String, int>>> unreadArticleCount(Ref ref) {
final repository = ref.watch(feedRepositoryProvider.notifier); final repository = ref.watch(feedRepositoryProvider.notifier);
return repository.watchUnreadFeedArticleCount(); return repository.watchUnreadFeedArticleCount();
} }
@Riverpod() @Riverpod()
Stream<int?> unreadFeedArticleCount(Ref ref, Uri feedId) { Stream<int?> unreadFeedArticleCount(Ref ref, Uri feedId) {
final stream = ref.watch(_unreadArticleCountProvider); final stream = ref.watch(unreadArticleCountProvider);
return stream.map((counts) => counts[feedId.toString()]); return stream.map((counts) => counts[feedId.toString()]);
} }
@Riverpod()
Future<FeedParseResult> fetchWebFeed(Ref ref, Uri url) {
return ref.read(feedReaderProvider.notifier).parseFeed(url);
}
+575 -30
View File
@@ -22,7 +22,7 @@ final feedListProvider = AutoDisposeStreamProvider<List<FeedData>>.internal(
@Deprecated('Will be removed in 3.0. Use Ref instead') @Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element // ignore: unused_element
typedef FeedListRef = AutoDisposeStreamProviderRef<List<FeedData>>; typedef FeedListRef = AutoDisposeStreamProviderRef<List<FeedData>>;
String _$feedArticleListHash() => r'64e834a1b69d913f1c860b38956b69af9e89a833'; String _$feedDataHash() => r'0599a2e3d159ef3abb6c3d2c871f87da2f5e646b';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {
@@ -45,6 +45,124 @@ class _SystemHash {
} }
} }
/// See also [feedData].
@ProviderFor(feedData)
const feedDataProvider = FeedDataFamily();
/// See also [feedData].
class FeedDataFamily extends Family<AsyncValue<FeedData?>> {
/// 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<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'feedDataProvider';
}
/// See also [feedData].
class FeedDataProvider extends AutoDisposeStreamProvider<FeedData?> {
/// 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<FeedData?> 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<FeedData?> 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<FeedData?> {
/// The parameter `feedId` of this provider.
Uri? get feedId;
}
class _FeedDataProviderElement
extends AutoDisposeStreamProviderElement<FeedData?>
with FeedDataRef {
_FeedDataProviderElement(super.provider);
@override
Uri? get feedId => (origin as FeedDataProvider).feedId;
}
String _$feedArticleListHash() => r'45d585cc9f59ad48a0d1d6fbcf802b1c7de7f6bc';
/// See also [feedArticleList]. /// See also [feedArticleList].
@ProviderFor(feedArticleList) @ProviderFor(feedArticleList)
const feedArticleListProvider = FeedArticleListFamily(); const feedArticleListProvider = FeedArticleListFamily();
@@ -55,15 +173,15 @@ class FeedArticleListFamily extends Family<AsyncValue<List<FeedArticle>>> {
const FeedArticleListFamily(); const FeedArticleListFamily();
/// See also [feedArticleList]. /// See also [feedArticleList].
FeedArticleListProvider call(FeedFilter filter) { FeedArticleListProvider call(Uri? feedId) {
return FeedArticleListProvider(filter); return FeedArticleListProvider(feedId);
} }
@override @override
FeedArticleListProvider getProviderOverride( FeedArticleListProvider getProviderOverride(
covariant FeedArticleListProvider provider, covariant FeedArticleListProvider provider,
) { ) {
return call(provider.filter); return call(provider.feedId);
} }
static const Iterable<ProviderOrFamily>? _dependencies = null; static const Iterable<ProviderOrFamily>? _dependencies = null;
@@ -85,9 +203,9 @@ class FeedArticleListFamily extends Family<AsyncValue<List<FeedArticle>>> {
class FeedArticleListProvider class FeedArticleListProvider
extends AutoDisposeStreamProvider<List<FeedArticle>> { extends AutoDisposeStreamProvider<List<FeedArticle>> {
/// See also [feedArticleList]. /// See also [feedArticleList].
FeedArticleListProvider(FeedFilter filter) FeedArticleListProvider(Uri? feedId)
: this._internal( : this._internal(
(ref) => feedArticleList(ref as FeedArticleListRef, filter), (ref) => feedArticleList(ref as FeedArticleListRef, feedId),
from: feedArticleListProvider, from: feedArticleListProvider,
name: r'feedArticleListProvider', name: r'feedArticleListProvider',
debugGetCreateSourceHash: debugGetCreateSourceHash:
@@ -97,7 +215,7 @@ class FeedArticleListProvider
dependencies: FeedArticleListFamily._dependencies, dependencies: FeedArticleListFamily._dependencies,
allTransitiveDependencies: allTransitiveDependencies:
FeedArticleListFamily._allTransitiveDependencies, FeedArticleListFamily._allTransitiveDependencies,
filter: filter, feedId: feedId,
); );
FeedArticleListProvider._internal( FeedArticleListProvider._internal(
@@ -107,10 +225,10 @@ class FeedArticleListProvider
required super.allTransitiveDependencies, required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash, required super.debugGetCreateSourceHash,
required super.from, required super.from,
required this.filter, required this.feedId,
}) : super.internal(); }) : super.internal();
final FeedFilter filter; final Uri? feedId;
@override @override
Override overrideWith( Override overrideWith(
@@ -125,7 +243,7 @@ class FeedArticleListProvider
dependencies: null, dependencies: null,
allTransitiveDependencies: null, allTransitiveDependencies: null,
debugGetCreateSourceHash: null, debugGetCreateSourceHash: null,
filter: filter, feedId: feedId,
), ),
); );
} }
@@ -137,13 +255,13 @@ class FeedArticleListProvider
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return other is FeedArticleListProvider && other.filter == filter; return other is FeedArticleListProvider && other.feedId == feedId;
} }
@override @override
int get hashCode { int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode); var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, filter.hashCode); hash = _SystemHash.combine(hash, feedId.hashCode);
return _SystemHash.finish(hash); return _SystemHash.finish(hash);
} }
@@ -152,8 +270,8 @@ class FeedArticleListProvider
@Deprecated('Will be removed in 3.0. Use Ref instead') @Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element // ignore: unused_element
mixin FeedArticleListRef on AutoDisposeStreamProviderRef<List<FeedArticle>> { mixin FeedArticleListRef on AutoDisposeStreamProviderRef<List<FeedArticle>> {
/// The parameter `filter` of this provider. /// The parameter `feedId` of this provider.
FeedFilter get filter; Uri? get feedId;
} }
class _FeedArticleListProviderElement class _FeedArticleListProviderElement
@@ -162,10 +280,10 @@ class _FeedArticleListProviderElement
_FeedArticleListProviderElement(super.provider); _FeedArticleListProviderElement(super.provider);
@override @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]. /// See also [feedArticle].
@ProviderFor(feedArticle) @ProviderFor(feedArticle)
@@ -177,15 +295,15 @@ class FeedArticleFamily extends Family<AsyncValue<FeedArticle?>> {
const FeedArticleFamily(); const FeedArticleFamily();
/// See also [feedArticle]. /// See also [feedArticle].
FeedArticleProvider call(String articleId) { FeedArticleProvider call(String articleId, {required bool updateReadDate}) {
return FeedArticleProvider(articleId); return FeedArticleProvider(articleId, updateReadDate: updateReadDate);
} }
@override @override
FeedArticleProvider getProviderOverride( FeedArticleProvider getProviderOverride(
covariant FeedArticleProvider provider, covariant FeedArticleProvider provider,
) { ) {
return call(provider.articleId); return call(provider.articleId, updateReadDate: provider.updateReadDate);
} }
static const Iterable<ProviderOrFamily>? _dependencies = null; static const Iterable<ProviderOrFamily>? _dependencies = null;
@@ -206,9 +324,13 @@ class FeedArticleFamily extends Family<AsyncValue<FeedArticle?>> {
/// See also [feedArticle]. /// See also [feedArticle].
class FeedArticleProvider extends AutoDisposeStreamProvider<FeedArticle?> { class FeedArticleProvider extends AutoDisposeStreamProvider<FeedArticle?> {
/// See also [feedArticle]. /// See also [feedArticle].
FeedArticleProvider(String articleId) FeedArticleProvider(String articleId, {required bool updateReadDate})
: this._internal( : this._internal(
(ref) => feedArticle(ref as FeedArticleRef, articleId), (ref) => feedArticle(
ref as FeedArticleRef,
articleId,
updateReadDate: updateReadDate,
),
from: feedArticleProvider, from: feedArticleProvider,
name: r'feedArticleProvider', name: r'feedArticleProvider',
debugGetCreateSourceHash: debugGetCreateSourceHash:
@@ -218,6 +340,7 @@ class FeedArticleProvider extends AutoDisposeStreamProvider<FeedArticle?> {
dependencies: FeedArticleFamily._dependencies, dependencies: FeedArticleFamily._dependencies,
allTransitiveDependencies: FeedArticleFamily._allTransitiveDependencies, allTransitiveDependencies: FeedArticleFamily._allTransitiveDependencies,
articleId: articleId, articleId: articleId,
updateReadDate: updateReadDate,
); );
FeedArticleProvider._internal( FeedArticleProvider._internal(
@@ -228,9 +351,11 @@ class FeedArticleProvider extends AutoDisposeStreamProvider<FeedArticle?> {
required super.debugGetCreateSourceHash, required super.debugGetCreateSourceHash,
required super.from, required super.from,
required this.articleId, required this.articleId,
required this.updateReadDate,
}) : super.internal(); }) : super.internal();
final String articleId; final String articleId;
final bool updateReadDate;
@override @override
Override overrideWith( Override overrideWith(
@@ -246,6 +371,7 @@ class FeedArticleProvider extends AutoDisposeStreamProvider<FeedArticle?> {
allTransitiveDependencies: null, allTransitiveDependencies: null,
debugGetCreateSourceHash: null, debugGetCreateSourceHash: null,
articleId: articleId, articleId: articleId,
updateReadDate: updateReadDate,
), ),
); );
} }
@@ -257,13 +383,16 @@ class FeedArticleProvider extends AutoDisposeStreamProvider<FeedArticle?> {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return other is FeedArticleProvider && other.articleId == articleId; return other is FeedArticleProvider &&
other.articleId == articleId &&
other.updateReadDate == updateReadDate;
} }
@override @override
int get hashCode { int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode); var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, articleId.hashCode); hash = _SystemHash.combine(hash, articleId.hashCode);
hash = _SystemHash.combine(hash, updateReadDate.hashCode);
return _SystemHash.finish(hash); return _SystemHash.finish(hash);
} }
@@ -274,6 +403,9 @@ class FeedArticleProvider extends AutoDisposeStreamProvider<FeedArticle?> {
mixin FeedArticleRef on AutoDisposeStreamProviderRef<FeedArticle?> { mixin FeedArticleRef on AutoDisposeStreamProviderRef<FeedArticle?> {
/// The parameter `articleId` of this provider. /// The parameter `articleId` of this provider.
String get articleId; String get articleId;
/// The parameter `updateReadDate` of this provider.
bool get updateReadDate;
} }
class _FeedArticleProviderElement class _FeedArticleProviderElement
@@ -283,17 +415,19 @@ class _FeedArticleProviderElement
@override @override
String get articleId => (origin as FeedArticleProvider).articleId; String get articleId => (origin as FeedArticleProvider).articleId;
@override
bool get updateReadDate => (origin as FeedArticleProvider).updateReadDate;
} }
String _$unreadArticleCountHash() => String _$unreadArticleCountHash() =>
r'6fb96215fb3b7739a6cbd594a0358415ced04fca'; r'709518ad229636df0f1095f47e3a6d116b3aa7e6';
/// See also [_unreadArticleCount]. /// See also [unreadArticleCount].
@ProviderFor(_unreadArticleCount) @ProviderFor(unreadArticleCount)
final _unreadArticleCountProvider = final unreadArticleCountProvider =
AutoDisposeProvider<Raw<Stream<Map<String, int>>>>.internal( AutoDisposeProvider<Raw<Stream<Map<String, int>>>>.internal(
_unreadArticleCount, unreadArticleCount,
name: r'_unreadArticleCountProvider', name: r'unreadArticleCountProvider',
debugGetCreateSourceHash: debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') const bool.fromEnvironment('dart.vm.product')
? null ? null
@@ -304,10 +438,10 @@ final _unreadArticleCountProvider =
@Deprecated('Will be removed in 3.0. Use Ref instead') @Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element // ignore: unused_element
typedef _UnreadArticleCountRef = typedef UnreadArticleCountRef =
AutoDisposeProviderRef<Raw<Stream<Map<String, int>>>>; AutoDisposeProviderRef<Raw<Stream<Map<String, int>>>>;
String _$unreadFeedArticleCountHash() => String _$unreadFeedArticleCountHash() =>
r'f8573674477f0d9813b42c82d75e8196c1c1445b'; r'5e0d8e58b3d1dec978dc07b22367f2cb037b1b15';
/// See also [unreadFeedArticleCount]. /// See also [unreadFeedArticleCount].
@ProviderFor(unreadFeedArticleCount) @ProviderFor(unreadFeedArticleCount)
@@ -429,5 +563,416 @@ class _UnreadFeedArticleCountProviderElement
Uri get feedId => (origin as UnreadFeedArticleCountProvider).feedId; 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<AsyncValue<FeedParseResult>> {
/// 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<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'fetchWebFeedProvider';
}
/// See also [fetchWebFeed].
class FetchWebFeedProvider extends AutoDisposeFutureProvider<FeedParseResult> {
/// 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<FeedParseResult> 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<FeedParseResult> 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<FeedParseResult> {
/// The parameter `url` of this provider.
Uri get url;
}
class _FetchWebFeedProviderElement
extends AutoDisposeFutureProviderElement<FeedParseResult>
with FetchWebFeedRef {
_FetchWebFeedProviderElement(super.provider);
@override
Uri get url => (origin as FetchWebFeedProvider).url;
}
String _$articleSearchHash() => r'8bf2c4aa8d8b3918be8a416909535682abfbab35';
abstract class _$ArticleSearch
extends BuildlessAutoDisposeStreamNotifier<List<FeedArticle>> {
late final Uri? feedId;
Stream<List<FeedArticle>> build(Uri? feedId);
}
/// See also [ArticleSearch].
@ProviderFor(ArticleSearch)
const articleSearchProvider = ArticleSearchFamily();
/// See also [ArticleSearch].
class ArticleSearchFamily extends Family<AsyncValue<List<FeedArticle>>> {
/// 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<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'articleSearchProvider';
}
/// See also [ArticleSearch].
class ArticleSearchProvider
extends
AutoDisposeStreamNotifierProviderImpl<
ArticleSearch,
List<FeedArticle>
> {
/// 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<List<FeedArticle>> 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<ArticleSearch, List<FeedArticle>>
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<List<FeedArticle>> {
/// The parameter `feedId` of this provider.
Uri? get feedId;
}
class _ArticleSearchProviderElement
extends
AutoDisposeStreamNotifierProviderElement<
ArticleSearch,
List<FeedArticle>
>
with ArticleSearchRef {
_ArticleSearchProviderElement(super.provider);
@override
Uri? get feedId => (origin as ArticleSearchProvider).feedId;
}
String _$filteredArticleListHash() =>
r'4443a4a92cab0c8f534a74a1cba681a8973b058e';
abstract class _$FilteredArticleList
extends BuildlessAutoDisposeNotifier<AsyncValue<List<FeedArticle>>> {
late final Uri? feedId;
AsyncValue<List<FeedArticle>> build(Uri? feedId);
}
/// See also [FilteredArticleList].
@ProviderFor(FilteredArticleList)
const filteredArticleListProvider = FilteredArticleListFamily();
/// See also [FilteredArticleList].
class FilteredArticleListFamily extends Family<AsyncValue<List<FeedArticle>>> {
/// 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<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'filteredArticleListProvider';
}
/// See also [FilteredArticleList].
class FilteredArticleListProvider
extends
AutoDisposeNotifierProviderImpl<
FilteredArticleList,
AsyncValue<List<FeedArticle>>
> {
/// 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<List<FeedArticle>> 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<List<FeedArticle>>
>
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<AsyncValue<List<FeedArticle>>> {
/// The parameter `feedId` of this provider.
Uri? get feedId;
}
class _FilteredArticleListProviderElement
extends
AutoDisposeNotifierProviderElement<
FilteredArticleList,
AsyncValue<List<FeedArticle>>
>
with FilteredArticleListRef {
_FilteredArticleListProviderElement(super.provider);
@override
Uri? get feedId => (origin as FilteredArticleListProvider).feedId;
}
// ignore_for_file: type=lint // 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 // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -1,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'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'article_filter.g.dart'; part 'article_filter.g.dart';
@@ -7,20 +5,17 @@ part 'article_filter.g.dart';
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class ArticleFilter extends _$ArticleFilter { class ArticleFilter extends _$ArticleFilter {
void addTag(String tagId) { void addTag(String tagId) {
final tags = {...?state.tags, tagId}; state = {...state, tagId};
state = state.copyWith.tags(tags);
} }
void removeTag(String tagId) { void removeTag(String tagId) {
if (state.tags.isNotEmpty) { if (state.isNotEmpty) {
final tags = {...state.tags!}..remove(tagId); state = {...state}..remove(tagId);
state = state.copyWith.tags(tags);
} }
} }
@override @override
FeedFilter build() { Set<String> build() {
return FeedFilter(); return {};
} }
} }
@@ -6,12 +6,12 @@ part of 'article_filter.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$articleFilterHash() => r'dfc997af8a33cbcb995288ef633ff321e95bae8d'; String _$articleFilterHash() => r'61e4d5230e214038e753bc173158ed1d4dc57040';
/// See also [ArticleFilter]. /// See also [ArticleFilter].
@ProviderFor(ArticleFilter) @ProviderFor(ArticleFilter)
final articleFilterProvider = final articleFilterProvider =
NotifierProvider<ArticleFilter, FeedFilter>.internal( NotifierProvider<ArticleFilter, Set<String>>.internal(
ArticleFilter.new, ArticleFilter.new,
name: r'articleFilterProvider', name: r'articleFilterProvider',
debugGetCreateSourceHash: debugGetCreateSourceHash:
@@ -22,6 +22,6 @@ final articleFilterProvider =
allTransitiveDependencies: null, allTransitiveDependencies: null,
); );
typedef _$ArticleFilter = Notifier<FeedFilter>; typedef _$ArticleFilter = Notifier<Set<String>>;
// ignore_for_file: type=lint // 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 // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -1,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/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_article.dart';
import 'package:lensai/features/web_feed/data/providers.dart'; import 'package:lensai/features/web_feed/data/providers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -13,11 +11,11 @@ class FeedRepository extends _$FeedRepository {
return ref.read(feedDatabaseProvider).feedDao.getFeeds().get(); return ref.read(feedDatabaseProvider).feedDao.getFeeds().get();
} }
Future<void> touchFeedFetched(Uri url) { Future<void> touchFeedFetched(Uri feedId) {
return ref return ref
.read(feedDatabaseProvider) .read(feedDatabaseProvider)
.feedDao .feedDao
.updateFeedFetched(url, DateTime.now()); .updateFeedFetched(feedId, DateTime.now());
} }
Future<void> upsertFeed(FeedData feedData) { Future<void> upsertFeed(FeedData feedData) {
@@ -28,8 +26,8 @@ class FeedRepository extends _$FeedRepository {
return ref.read(feedDatabaseProvider).articleDao.upsertArticles(articles); return ref.read(feedDatabaseProvider).articleDao.upsertArticles(articles);
} }
Future<int> deleteFeed(Uri url) { Future<int> deleteFeed(Uri feedId) {
return ref.read(feedDatabaseProvider).feedDao.deleteFeed(url); return ref.read(feedDatabaseProvider).feedDao.deleteFeed(feedId);
} }
Future<void> touchArticleRead(String articleId) { Future<void> touchArticleRead(String articleId) {
@@ -50,46 +48,20 @@ class FeedRepository extends _$FeedRepository {
return ref.read(feedDatabaseProvider).feedDao.getFeeds().watch(); return ref.read(feedDatabaseProvider).feedDao.getFeeds().watch();
} }
Stream<List<FeedArticle>> watchFeedArticles( Stream<FeedData?> watchFeed(Uri feedId) {
FeedFilter filter, { return ref
int snippetLength = 120,
String matchPrefix = '***',
String matchSuffix = '***',
String ellipsis = '',
}) {
final stream =
filter.query.isNotEmpty
? ref
.read(feedDatabaseProvider) .read(feedDatabaseProvider)
.articleDao .feedDao
.queryArticles( .getFeed(feedId)
matchPrefix: matchPrefix, .watchSingleOrNull();
matchSuffix: matchSuffix,
ellipsis: ellipsis,
snippetLength: snippetLength,
searchString: filter.query!,
feedId: filter.feedId,
)
.watch()
: ref
.read(feedDatabaseProvider)
.articleDao
.getFeedArticles(filter.feedId)
.watch();
if (filter.tags.isNotEmpty) {
return stream.map(
(articles) =>
articles
.where(
(article) =>
article.tags?.toSet().containsAll(filter.tags!) ?? false,
)
.toList(),
);
} else {
return stream;
} }
Stream<List<FeedArticle>> watchFeedArticles(Uri? feedId) {
return ref
.read(feedDatabaseProvider)
.articleDao
.getFeedArticles(feedId)
.watch();
} }
Stream<FeedArticle?> watchArticle(String articleId) { Stream<FeedArticle?> watchArticle(String articleId) {
@@ -6,7 +6,7 @@ part of 'feed_repository.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$feedRepositoryHash() => r'6a050933a6a4eafbe76a66262e1b64bd838a1078'; String _$feedRepositoryHash() => r'805cc26890b0d43576a1eab0ecb5851b6649d7b7';
/// See also [FeedRepository]. /// See also [FeedRepository].
@ProviderFor(FeedRepository) @ProviderFor(FeedRepository)
@@ -26,6 +26,12 @@ extension ParseAtomLink on List<AtomLink> {
} }
} }
extension SelectFeedLink on List<FeedLink> {
FeedLink? getRelation(FeedLinkRelation relation) {
return firstWhereOrNull((link) => link.relation == relation);
}
}
extension ParseAtomCategory on List<AtomCategory> { extension ParseAtomCategory on List<AtomCategory> {
List<FeedCategory> toFeedCategories() { List<FeedCategory> toFeedCategories() {
return where((category) => category.term.isNotEmpty) return where((category) => category.term.isNotEmpty)
@@ -1,15 +1,13 @@
import 'package:collection/collection.dart';
import 'package:lensai/extensions/nullable.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_article.dart';
import 'package:lensai/features/web_feed/data/models/feed_link.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 { extension FeedArticleX on FeedArticle {
String get displayTitle => String get displayTitle =>
title ?? title ??
links links
?.firstWhereOrNull( ?.getRelation(FeedLinkRelation.alternate)
(link) => link.relation == FeedLinkRelation.alternate,
)
.mapNotNull( .mapNotNull(
(link) => link.title.whenNotEmpty ?? link.uri.toString(), (link) => link.title.whenNotEmpty ?? link.uri.toString(),
) ?? ) ??
@@ -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<FormState>());
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'),
),
],
);
}
}
@@ -2,7 +2,6 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/providers/format.dart'; import 'package:lensai/core/providers/format.dart';
import 'package:lensai/core/routing/routes.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/geckoview/domain/repositories/tab.dart';
import 'package:lensai/features/web_feed/data/models/feed_link.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/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/extensions/feed_article.dart';
import 'package:lensai/features/web_feed/presentation/widgets/authors_horizontal_list.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'; import 'package:lensai/features/web_feed/presentation/widgets/tags_horizontal_list.dart';
@@ -26,13 +26,16 @@ class FeedArticleScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final articleAsync = ref.watch(feedArticleProvider(articleId)); final articleAsync = ref.watch(
feedArticleProvider(articleId, updateReadDate: true),
);
return Scaffold( return Scaffold(
body: articleAsync.when( body: articleAsync.when(
skipLoadingOnReload: true,
data: (article) { data: (article) {
if (article == null) { if (article == null) {
return SizedBox.shrink(); return const SizedBox.shrink();
} }
return HookBuilder( return HookBuilder(
@@ -55,9 +58,7 @@ class FeedArticleScreen extends HookConsumerWidget {
); );
final articleLink = useMemoized( final articleLink = useMemoized(
() => article.links?.firstWhereOrNull( () => article.links?.getRelation(FeedLinkRelation.alternate),
(link) => link.relation == FeedLinkRelation.alternate,
),
); );
final articleImages = useMemoized( final articleImages = useMemoized(
@@ -176,7 +177,7 @@ class FeedArticleScreen extends HookConsumerWidget {
.addTab(url: articleLink.uri); .addTab(url: articleLink.uri);
if (context.mounted) { if (context.mounted) {
context.go(BrowserRoute().location); BrowserRoute().go(context);
} }
}, },
icon: const Icon(Icons.open_in_browser), icon: const Icon(Icons.open_in_browser),
@@ -218,7 +219,7 @@ class FeedArticleScreen extends HookConsumerWidget {
context, context,
tabName: title.whenNotEmpty, tabName: title.whenNotEmpty,
onShow: () { onShow: () {
context.go(BrowserRoute().location); BrowserRoute().go(context);
}, },
); );
} }
@@ -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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/extensions/nullable.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.dart';
import 'package:lensai/features/web_feed/domain/providers/article_filter.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/controllers/fetch_articles.dart';
import 'package:lensai/features/web_feed/presentation/widgets/feed_article_card.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/failure_widget.dart';
import 'package:lensai/presentation/widgets/speech_to_text_button.dart';
class FeedArticleListScreen extends HookConsumerWidget { class FeedArticleListScreen extends HookConsumerWidget {
final Uri? feedId; final Uri? feedId;
@@ -17,26 +18,130 @@ class FeedArticleListScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final articlesAsync = ref.watch(
// ignore: provider_parameters
feedArticleListProvider(FeedFilter(feedId: feedId)),
);
return Scaffold( return Scaffold(
body: NestedScrollView( body: NestedScrollView(
floatHeaderSlivers: true, floatHeaderSlivers: true,
headerSliverBuilder: (context, innerBoxIsScrolled) { headerSliverBuilder: (context, innerBoxIsScrolled) {
return [ return [
Consumer( HookConsumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final tags = ref.watch(articleFilterProvider); 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( body: Consumer(
builder: (context, ref, child) {
final articlesAsync = ref.watch(
// ignore: provider_parameters
filteredArticleListProvider(feedId),
);
return articlesAsync.when(
skipLoadingOnReload: true,
data: (articles) { data: (articles) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
@@ -50,38 +155,20 @@ class FeedArticleListScreen extends HookConsumerWidget {
.fetchAllArticles(); .fetchAllArticles();
} }
}, },
child: MediaQuery.removePadding(
removeTop: true,
context: context,
child: ListView.builder( child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: articles.length, itemCount: articles.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final article = articles[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( return FeedArticleCard(
selectedTags: tags.value, key: ValueKey(article.id),
onTagSelected: (tagId, value) {
if (value) {
ref
.read(articleFilterProvider.notifier)
.addTag(tagId);
} else {
ref
.read(articleFilterProvider.notifier)
.removeTag(tagId);
}
},
article: article, article: article,
); );
}, },
); ),
},
), ),
); );
}, },
@@ -93,6 +180,8 @@ class FeedArticleListScreen extends HookConsumerWidget {
), ),
), ),
loading: () => const SizedBox.shrink(), loading: () => const SizedBox.shrink(),
);
},
), ),
), ),
); );
@@ -1,33 +1,109 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/extensions/nullable.dart';
import 'package:lensai/extensions/uri.dart'; import 'package:lensai/extensions/uri.dart';
import 'package:lensai/features/web_feed/data/database/database.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/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/domain/repositories/feed_repository.dart';
import 'package:lensai/features/web_feed/presentation/widgets/tag_field.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/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 } enum _DialogMode { create, edit }
class FeedEditScreen extends HookConsumerWidget { class FeedEditScreen extends HookConsumerWidget {
final _DialogMode _mode; 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; : _mode = mode;
factory FeedEditScreen.create({required FeedData initialFeed}) { factory FeedEditScreen.create({required Uri feedId}) {
return FeedEditScreen._(mode: _DialogMode.create, initialFeed: initialFeed); return FeedEditScreen._(mode: _DialogMode.create, feedId: feedId);
} }
factory FeedEditScreen.edit({required FeedData initialFeed}) { factory FeedEditScreen.edit({required Uri feedId}) {
return FeedEditScreen._(mode: _DialogMode.edit, initialFeed: initialFeed); 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>()); final formKey = useMemoized(() => GlobalKey<FormState>());
@@ -45,6 +121,12 @@ class FeedEditScreen extends HookConsumerWidget {
final urlTextController = useTextEditingController( final urlTextController = useTextEditingController(
text: initialFeed.url.toString(), text: initialFeed.url.toString(),
); );
final iconUrlTextController = useTextEditingController(
text: initialFeed.icon?.toString(),
);
final siteLinkTextController = useTextEditingController(
text: initialFeed.siteLink?.toString(),
);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -57,9 +139,21 @@ class FeedEditScreen extends HookConsumerWidget {
onPressed: () async { onPressed: () async {
if (formKey.currentState?.validate() ?? false) { if (formKey.currentState?.validate() ?? false) {
final feedData = FeedData( final feedData = FeedData(
url: Uri.parse(urlTextController.text), url:
uri_parser.tryParseUrl(
urlTextController.text,
eagerParsing: true,
)!,
authors: initialFeed.authors, authors: initialFeed.authors,
description: descriptionTextController.text.whenNotEmpty, 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(), tags: tags.value.map((tag) => FeedCategory(id: tag)).toList(),
title: titleTextController.text.whenNotEmpty, title: titleTextController.text.whenNotEmpty,
); );
@@ -91,10 +185,11 @@ class FeedEditScreen extends HookConsumerWidget {
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: Padding( prefixIcon: Padding(
padding: const EdgeInsets.all(10.0), padding: const EdgeInsets.all(10.0),
child: UrlIcon( child: UrlIcon([
initialFeed.icon ??
initialFeed.siteLink ??
initialFeed.url.base, initialFeed.url.base,
iconSize: 24.0, ], iconSize: 24.0),
),
), ),
label: const Text('Title'), label: const Text('Title'),
), ),
@@ -103,40 +198,63 @@ class FeedEditScreen extends HookConsumerWidget {
TextFormField( TextFormField(
decoration: const InputDecoration( decoration: const InputDecoration(
label: Text('Description'), label: Text('Description'),
prefixIcon: Icon(Icons.short_text),
), ),
minLines: 1, minLines: 1,
maxLines: 3, maxLines: 3,
controller: descriptionTextController, 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( TagField(
initialTags: tags.value, initialTags: tags.value,
onTagsUpdate: (newTags) { onTagsUpdate: (newTags) {
tags.value = newTags; tags.value = newTags;
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 32),
TextFormField( TextFormField(
decoration: const InputDecoration( decoration: const InputDecoration(
label: Text('Address'), label: Text('Feed URL'),
prefixIcon: Icon(MdiIcons.rss),
), ),
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
controller: urlTextController, controller: urlTextController,
autovalidateMode: AutovalidateMode.onUserInteraction, autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (value) { validator: (value) {
if (value.isEmpty) { return validateUrl(value, onlyHttpProtocol: true);
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';
}, },
), ),
], ],
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/domain/providers.dart';
import 'package:lensai/features/web_feed/presentation/controllers/fetch_articles.dart'; import 'package:lensai/features/web_feed/presentation/controllers/fetch_articles.dart';
import 'package:lensai/features/web_feed/presentation/widgets/feed_card.dart'; import 'package:lensai/features/web_feed/presentation/widgets/feed_card.dart';
@@ -15,6 +16,7 @@ class FeedListScreen extends HookConsumerWidget {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Feeds')), appBar: AppBar(title: const Text('Feeds')),
body: feeds.when( body: feeds.when(
skipLoadingOnReload: true,
data: (feeds) { data: (feeds) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
@@ -35,10 +37,21 @@ class FeedListScreen extends HookConsumerWidget {
child: FailureWidget( child: FailureWidget(
title: 'Failed to load Feeds', title: 'Failed to load Feeds',
exception: error, exception: error,
onRetry: () {
// ignore: unused_result
ref.refresh(feedListProvider);
},
), ),
), ),
loading: () => const SizedBox.shrink(), loading: () => const SizedBox.shrink(),
), ),
floatingActionButton: FloatingActionButton.extended(
label: const Text('Feed'),
icon: const Icon(Icons.add),
onPressed: () async {
await const FeedAddRoute().push(context);
},
),
); );
} }
} }
@@ -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<Uri> 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(),
);
}
}
@@ -6,18 +6,31 @@ import 'package:lensai/features/web_feed/data/models/feed_author.dart';
class AuthorsHorizontalList extends StatelessWidget { class AuthorsHorizontalList extends StatelessWidget {
late final List<Widget> _authors; late final List<Widget> _authors;
AuthorsHorizontalList({required List<FeedAuthor> authors}) { AuthorsHorizontalList({
required List<FeedAuthor> authors,
Set<String> selectedTags = const {},
void Function(String tagId, bool value)? onTagSelected,
}) {
_authors = _authors =
authors authors.map((author) {
.map( final label = Text(
(author) => Chip(
label: Text(
'${author.name ?? ''} ${author.email.mapNotNull((email) => '($email)') ?? ''}' '${author.name ?? ''} ${author.email.mapNotNull((email) => '($email)') ?? ''}'
.trim(), .trim(),
);
return onTagSelected.mapNotNull(
(onTagSelected) => FilterChip(
label: label,
selected: selectedTags.contains(author.name),
onSelected: (value) {
if (author.name.isNotEmpty) {
onTagSelected(author.name!, value);
}
},
), ),
), ) ??
) Chip(label: label);
.toList(); }).toList();
} }
@override @override
@@ -30,7 +43,6 @@ class AuthorsHorizontalList extends StatelessWidget {
return ListView.builder( return ListView.builder(
itemCount: _authors.length, itemCount: _authors.length,
controller: controller, controller: controller,
shrinkWrap: true,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => _authors[index], itemBuilder: (context, index) => _authors[index],
); );
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; 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:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/core/routing/routes.dart';
import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/extensions/nullable.dart';
import 'package:lensai/extensions/uri.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.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/domain/repositories/feed_repository.dart';
import 'package:lensai/features/web_feed/extensions/feed_article.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/authors_horizontal_list.dart';
@@ -15,34 +17,31 @@ import 'package:timeago/timeago.dart' as timeago;
class FeedArticleCard extends HookConsumerWidget { class FeedArticleCard extends HookConsumerWidget {
final FeedArticle article; final FeedArticle article;
final Set<String> selectedTags; const FeedArticleCard({super.key, required this.article});
final void Function(String tagId, bool value)? onTagSelected;
const FeedArticleCard({
super.key,
required this.article,
this.onTagSelected,
this.selectedTags = const {},
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context); 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( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
onTap: () async { onTap: () async {
await ref await FeedArticleRoute(articleId: article.id).push(context);
.read(feedRepositoryProvider.notifier)
.touchArticleRead(article.id);
if (context.mounted) {
await context.push(
FeedArticleRoute(articleId: article.id).location,
extra: article,
);
}
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -52,17 +51,55 @@ class FeedArticleCard extends HookConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
UrlIcon(article.feedId.base, iconSize: 34.0), UrlIcon([
article.icon ?? article.feedId.base,
], iconSize: 34.0),
const SizedBox(width: 12.0), const SizedBox(width: 12.0),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
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( Text(
article.displayTitle, article.displayTitle,
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
), ),
if (article.summaryPlain != null) 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( Text(
article.summaryPlain!, article.summaryPlain!,
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
@@ -86,15 +123,35 @@ class FeedArticleCard extends HookConsumerWidget {
if (article.authors.isNotEmpty || article.tags.isNotEmpty) ...[ if (article.authors.isNotEmpty || article.tags.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
if (article.authors.isNotEmpty) 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) if (article.tags.isNotEmpty)
TagsHorizontalList( TagsHorizontalList(
tags: article.tags!, tags: article.tags!,
selectedTags: selectedTags, selectedTags: tags,
onTagSelected: onTagSelected, onTagSelected: (tagId, value) {
if (value) {
ref.read(articleFilterProvider.notifier).addTag(tagId);
} else {
ref
.read(articleFilterProvider.notifier)
.removeTag(tagId);
}
},
), ),
const Divider(),
], ],
const Divider(),
Row( Row(
children: [ children: [
Text( Text(
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/core/routing/routes.dart';
import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/extensions/nullable.dart';
@@ -26,7 +25,7 @@ class FeedCard extends HookConsumerWidget {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
onTap: () async { onTap: () async {
await context.push(FeedArticleListRoute(feedId: feed.url).location); await FeedArticleListRoute(feedId: feed.url).push(context);
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -36,7 +35,9 @@ class FeedCard extends HookConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
UrlIcon(feed.url.base, iconSize: 34.0), UrlIcon([
feed.icon ?? feed.siteLink ?? feed.url.base,
], iconSize: 34.0),
const SizedBox(width: 12.0), const SizedBox(width: 12.0),
Expanded( Expanded(
child: Column( 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) ...[ if (feed.authors.isNotEmpty || feed.tags.isNotEmpty) ...[
@@ -89,6 +96,7 @@ class FeedCard extends HookConsumerWidget {
); );
return countAsync.when( return countAsync.when(
skipLoadingOnReload: true,
data: (count) { data: (count) {
if (count == null) { if (count == null) {
return const SizedBox(); return const SizedBox();
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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'; import 'package:lensai/presentation/hooks/listenable_callback.dart';
final _tagSplitPatter = RegExp(r'[,\s]+'); final _tagSplitPatter = RegExp(r'[,\s]+');
@@ -45,9 +46,8 @@ class TagField extends HookWidget {
TextField( TextField(
controller: textController, controller: textController,
decoration: const InputDecoration( decoration: const InputDecoration(
label: Text('Add'),
floatingLabelBehavior: FloatingLabelBehavior.always,
hintText: 'tag1, tag2, ...', hintText: 'tag1, tag2, ...',
prefixIcon: Icon(MdiIcons.tagMultiple),
), ),
onChanged: (String value) { onChanged: (String value) {
if (value.isNotEmpty) { if (value.isNotEmpty) {
@@ -12,25 +12,27 @@ class TagsHorizontalList extends StatelessWidget {
void Function(String tagId, bool value)? onTagSelected, void Function(String tagId, bool value)? onTagSelected,
}) { }) {
_tags = _tags =
tags tags.map((tag) {
.map( final label = Text(
(tag) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: FilterChip(
label: Text(
'${tag.id} ${tag.title.mapNotNull((title) => '($title)') ?? ''}' '${tag.id} ${tag.title.mapNotNull((title) => '($title)') ?? ''}'
.trim(), .trim(),
), );
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child:
onTagSelected.mapNotNull(
(onTagSelected) => FilterChip(
label: label,
selected: selectedTags.contains(tag.id), selected: selectedTags.contains(tag.id),
onSelected: onTagSelected.mapNotNull( onSelected: (value) {
(onTagSelected) => (value) {
onTagSelected(tag.id, value); onTagSelected(tag.id, value);
}, },
), ),
), ) ??
), Chip(label: label),
) );
.toList(); }).toList();
} }
@override @override
@@ -43,7 +45,8 @@ class TagsHorizontalList extends StatelessWidget {
return ListView.builder( return ListView.builder(
itemCount: _tags.length, itemCount: _tags.length,
controller: controller, controller: controller,
shrinkWrap: true, //Improve list performance by not rendering outside screen at all
cacheExtent: 0,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => _tags[index], itemBuilder: (context, index) => _tags[index],
); );
@@ -32,7 +32,7 @@ class FeedFinder {
// return results; // return results;
// } // }
void _parseBody(Set<String> candidates) { void _parseBody(Set<Uri> candidates) {
for (final a in document.querySelectorAll('a')) { for (final a in document.querySelectorAll('a')) {
var href = a.attributes['href']; var href = a.attributes['href'];
if (href != null) { if (href != null) {
@@ -46,13 +46,15 @@ class FeedFinder {
// Fix naked URLs // Fix naked URLs
href = !href.startsWith('http') ? '$_base/$href' : href; href = !href.startsWith('http') ? '$_base/$href' : href;
candidates.add(href); if (Uri.tryParse(href) case final Uri uri) {
candidates.add(uri);
}
} }
} }
} }
} }
void _parseHead(Set<String> candidates) { void _parseHead(Set<Uri> candidates) {
for (final link in document.querySelectorAll("link[rel='alternate']")) { for (final link in document.querySelectorAll("link[rel='alternate']")) {
final type = link.attributes['type']; final type = link.attributes['type'];
if (type != null) { if (type != null) {
@@ -61,19 +63,22 @@ class FeedFinder {
if (href != null) { if (href != null) {
// Fix relative URLs // Fix relative URLs
href = href.startsWith('/') ? _base + href : href; href = href.startsWith('/') ? _base + href : href;
candidates.add(href);
if (Uri.tryParse(href) case final Uri uri) {
candidates.add(uri);
}
} }
} }
} }
} }
} }
Future<Set<String>> parse({ Future<Set<Uri>> parse({
bool parseHead = true, bool parseHead = true,
bool parseBody = true, bool parseBody = true,
// bool verifyCandidates = true, // bool verifyCandidates = true,
}) async { }) async {
final candidates = <String>{}; final candidates = <Uri>{};
// Look for feed candidates in head // Look for feed candidates in head
if (parseHead) { if (parseHead) {
@@ -38,6 +38,7 @@ class FeedParser {
url: url, url: url,
title: feed.title.whenNotEmpty ?? feed.dc?.title, title: feed.title.whenNotEmpty ?? feed.dc?.title,
description: feed.description.whenNotEmpty ?? feed.dc?.description, description: feed.description.whenNotEmpty ?? feed.dc?.description,
siteLink: feed.link.mapNotNull(Uri.tryParse),
authors: feed.dc?.creator.whenNotEmpty.mapNotNull( authors: feed.dc?.creator.whenNotEmpty.mapNotNull(
(creator) => [FeedAuthor(name: creator)], (creator) => [FeedAuthor(name: creator)],
), ),
@@ -50,6 +51,7 @@ class FeedParser {
url: url, url: url,
title: feed.title.whenNotEmpty ?? feed.dc?.title, title: feed.title.whenNotEmpty ?? feed.dc?.title,
description: feed.description.whenNotEmpty ?? feed.dc?.description, description: feed.description.whenNotEmpty ?? feed.dc?.description,
siteLink: feed.link.mapNotNull(Uri.tryParse),
authors: (feed.author.whenNotEmpty ?? feed.dc?.creator.whenNotEmpty) authors: (feed.author.whenNotEmpty ?? feed.dc?.creator.whenNotEmpty)
.mapNotNull((creator) => [FeedAuthor(name: creator)]), .mapNotNull((creator) => [FeedAuthor(name: creator)]),
tags: tags:
@@ -62,6 +64,12 @@ class FeedParser {
return FeedData( return FeedData(
url: url, url: url,
title: feed.title.whenNotEmpty, title: feed.title.whenNotEmpty,
icon: feed.icon.mapNotNull(Uri.tryParse),
siteLink:
feed.links
.toFeedLinks()
.getRelation(FeedLinkRelation.alternate)
?.uri,
description: feed.subtitle.whenNotEmpty, description: feed.subtitle.whenNotEmpty,
authors: authors.isNotEmpty ? authors : null, authors: authors.isNotEmpty ? authors : null,
tags: tags, tags: tags,
@@ -76,15 +84,19 @@ class FeedParser {
switch (_feed) { switch (_feed) {
case final Rss1Feed feed: case final Rss1Feed feed:
final processedContents = await GeckoTurndownService().turndownHtml( final processedContents =
await GeckoBrowserExtensionService.turndownHtml(
feed.items.map((item) => item.content?.value ?? '').toList(), feed.items.map((item) => item.content?.value ?? '').toList(),
); );
final processedSummaries = await GeckoTurndownService().turndownHtml( final processedSummaries =
await GeckoBrowserExtensionService.turndownHtml(
feed.items feed.items
.map( .map(
(item) => (item) =>
item.description.whenNotEmpty ?? item.dc?.description ?? '', item.description.whenNotEmpty ??
item.dc?.description ??
'',
) )
.toList(), .toList(),
); );
@@ -116,15 +128,19 @@ class FeedParser {
); );
}).toList(); }).toList();
case final RssFeed feed: case final RssFeed feed:
final processedContents = await GeckoTurndownService().turndownHtml( final processedContents =
await GeckoBrowserExtensionService.turndownHtml(
feed.items.map((item) => item.content?.value ?? '').toList(), feed.items.map((item) => item.content?.value ?? '').toList(),
); );
final processedSummaries = await GeckoTurndownService().turndownHtml( final processedSummaries =
await GeckoBrowserExtensionService.turndownHtml(
feed.items feed.items
.map( .map(
(item) => (item) =>
item.description.whenNotEmpty ?? item.dc?.description ?? '', item.description.whenNotEmpty ??
item.dc?.description ??
'',
) )
.toList(), .toList(),
); );
@@ -169,16 +185,18 @@ class FeedParser {
); );
}).toList(); }).toList();
case final AtomFeed feed: case final AtomFeed feed:
final processedContents = await GeckoTurndownService().turndownHtml( final processedContents =
await GeckoBrowserExtensionService.turndownHtml(
feed.items.map((item) => item.content ?? '').toList(), feed.items.map((item) => item.content ?? '').toList(),
); );
final processedSummaries = await GeckoTurndownService().turndownHtml( final processedSummaries =
await GeckoBrowserExtensionService.turndownHtml(
feed.items.map((item) => item.summary ?? '').toList(), feed.items.map((item) => item.summary ?? '').toList(),
); );
final feedLink = feed.links.toFeedLinks().firstWhereOrNull( final feedLink = feed.links.toFeedLinks().getRelation(
(link) => link.relation == FeedLinkRelation.self, FeedLinkRelation.self,
); );
return feed.items.mapIndexed((i, item) { return feed.items.mapIndexed((i, item) {
@@ -187,9 +205,7 @@ class FeedParser {
final tags = item.categories.toFeedCategories(); final tags = item.categories.toFeedCategories();
final itemLinks = item.links.toFeedLinks(); final itemLinks = item.links.toFeedLinks();
final articleLink = itemLinks.firstWhereOrNull( final articleLink = itemLinks.getRelation(FeedLinkRelation.alternate);
(link) => link.relation == FeedLinkRelation.alternate,
);
final itemId = item.id.whenNotEmpty ?? item.title; final itemId = item.id.whenNotEmpty ?? item.title;
final uniqueId = final uniqueId =
@@ -6,10 +6,14 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'website_title.g.dart'; part 'website_title.g.dart';
@Riverpod() @Riverpod()
Future<WebPageInfo> pageInfo(Ref ref, Uri url) async { Future<WebPageInfo> pageInfo(
Ref ref,
Uri url, {
required bool isImageRequest,
}) async {
final websiteService = ref.watch(genericWebsiteServiceProvider.notifier); final websiteService = ref.watch(genericWebsiteServiceProvider.notifier);
final result = await websiteService.fetchPageInfo(url); final result = await websiteService.fetchPageInfo(url, isImageRequest);
if (result.isSuccess) { if (result.isSuccess) {
ref.keepAlive(); ref.keepAlive();
@@ -6,7 +6,7 @@ part of 'website_title.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$pageInfoHash() => r'bdb860ec904959d7aa561045b85b75b209fb38e1'; String _$pageInfoHash() => r'dd5644057e4ba7280275105de4788eb4046592d4';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {
@@ -39,13 +39,13 @@ class PageInfoFamily extends Family<AsyncValue<WebPageInfo>> {
const PageInfoFamily(); const PageInfoFamily();
/// See also [pageInfo]. /// See also [pageInfo].
PageInfoProvider call(Uri url) { PageInfoProvider call(Uri url, {required bool isImageRequest}) {
return PageInfoProvider(url); return PageInfoProvider(url, isImageRequest: isImageRequest);
} }
@override @override
PageInfoProvider getProviderOverride(covariant PageInfoProvider provider) { PageInfoProvider getProviderOverride(covariant PageInfoProvider provider) {
return call(provider.url); return call(provider.url, isImageRequest: provider.isImageRequest);
} }
static const Iterable<ProviderOrFamily>? _dependencies = null; static const Iterable<ProviderOrFamily>? _dependencies = null;
@@ -66,9 +66,10 @@ class PageInfoFamily extends Family<AsyncValue<WebPageInfo>> {
/// See also [pageInfo]. /// See also [pageInfo].
class PageInfoProvider extends AutoDisposeFutureProvider<WebPageInfo> { class PageInfoProvider extends AutoDisposeFutureProvider<WebPageInfo> {
/// See also [pageInfo]. /// See also [pageInfo].
PageInfoProvider(Uri url) PageInfoProvider(Uri url, {required bool isImageRequest})
: this._internal( : this._internal(
(ref) => pageInfo(ref as PageInfoRef, url), (ref) =>
pageInfo(ref as PageInfoRef, url, isImageRequest: isImageRequest),
from: pageInfoProvider, from: pageInfoProvider,
name: r'pageInfoProvider', name: r'pageInfoProvider',
debugGetCreateSourceHash: debugGetCreateSourceHash:
@@ -78,6 +79,7 @@ class PageInfoProvider extends AutoDisposeFutureProvider<WebPageInfo> {
dependencies: PageInfoFamily._dependencies, dependencies: PageInfoFamily._dependencies,
allTransitiveDependencies: PageInfoFamily._allTransitiveDependencies, allTransitiveDependencies: PageInfoFamily._allTransitiveDependencies,
url: url, url: url,
isImageRequest: isImageRequest,
); );
PageInfoProvider._internal( PageInfoProvider._internal(
@@ -88,9 +90,11 @@ class PageInfoProvider extends AutoDisposeFutureProvider<WebPageInfo> {
required super.debugGetCreateSourceHash, required super.debugGetCreateSourceHash,
required super.from, required super.from,
required this.url, required this.url,
required this.isImageRequest,
}) : super.internal(); }) : super.internal();
final Uri url; final Uri url;
final bool isImageRequest;
@override @override
Override overrideWith( Override overrideWith(
@@ -106,6 +110,7 @@ class PageInfoProvider extends AutoDisposeFutureProvider<WebPageInfo> {
allTransitiveDependencies: null, allTransitiveDependencies: null,
debugGetCreateSourceHash: null, debugGetCreateSourceHash: null,
url: url, url: url,
isImageRequest: isImageRequest,
), ),
); );
} }
@@ -117,13 +122,16 @@ class PageInfoProvider extends AutoDisposeFutureProvider<WebPageInfo> {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return other is PageInfoProvider && other.url == url; return other is PageInfoProvider &&
other.url == url &&
other.isImageRequest == isImageRequest;
} }
@override @override
int get hashCode { int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode); var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, url.hashCode); hash = _SystemHash.combine(hash, url.hashCode);
hash = _SystemHash.combine(hash, isImageRequest.hashCode);
return _SystemHash.finish(hash); return _SystemHash.finish(hash);
} }
@@ -134,6 +142,9 @@ class PageInfoProvider extends AutoDisposeFutureProvider<WebPageInfo> {
mixin PageInfoRef on AutoDisposeFutureProviderRef<WebPageInfo> { mixin PageInfoRef on AutoDisposeFutureProviderRef<WebPageInfo> {
/// The parameter `url` of this provider. /// The parameter `url` of this provider.
Uri get url; Uri get url;
/// The parameter `isImageRequest` of this provider.
bool get isImageRequest;
} }
class _PageInfoProviderElement class _PageInfoProviderElement
@@ -143,6 +154,8 @@ class _PageInfoProviderElement
@override @override
Uri get url => (origin as PageInfoProvider).url; Uri get url => (origin as PageInfoProvider).url;
@override
bool get isImageRequest => (origin as PageInfoProvider).isImageRequest;
} }
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -64,7 +64,6 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
return ListView.builder( return ListView.builder(
controller: controller, controller: controller,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
shrinkWrap: true,
itemCount: items.length, itemCount: items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = items[index]; final item = items[index];
+4 -4
View File
@@ -7,17 +7,17 @@ import 'package:skeletonizer/skeletonizer.dart';
class UrlIcon extends HookConsumerWidget { class UrlIcon extends HookConsumerWidget {
final double iconSize; final double iconSize;
final Uri url; final List<Uri> urlList;
const UrlIcon(this.url, {required this.iconSize, super.key}); const UrlIcon(this.urlList, {required this.iconSize, super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final icon = useCachedFuture( final icon = useCachedFuture(
() => () =>
// ignore: discarded_futures // ignore: discarded_futures
ref.read(genericWebsiteServiceProvider.notifier).getUrlIcon(url), ref.read(genericWebsiteServiceProvider.notifier).getUrlIcon(urlList),
[url], [urlList],
); );
return Skeletonizer( return Skeletonizer(
@@ -1,5 +1,8 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/data/models/web_page_info.dart';
import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/extensions/nullable.dart';
import 'package:lensai/presentation/controllers/website_title.dart'; import 'package:lensai/presentation/controllers/website_title.dart';
@@ -17,11 +20,12 @@ class WebsiteFeedTile extends HookConsumerWidget {
final pageInfoAsync = final pageInfoAsync =
(precachedInfo?.feeds != null) (precachedInfo?.feeds != null)
? AsyncValue.data(precachedInfo!) ? AsyncValue.data(precachedInfo!)
: ref.watch(pageInfoProvider(url)); : ref.watch(pageInfoProvider(url, isImageRequest: false));
return Skeletonizer( return Skeletonizer(
enabled: pageInfoAsync.isLoading && precachedInfo?.feeds == null, enabled: pageInfoAsync.isLoading && precachedInfo?.feeds == null,
child: pageInfoAsync.when( child: pageInfoAsync.when(
skipLoadingOnReload: true,
data: (info) { data: (info) {
if (info.feeds.isEmpty) { if (info.feeds.isEmpty) {
return const SizedBox.shrink(); 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) { error: (error, stackTrace) {
@@ -16,11 +16,12 @@ class WebsiteTitleTile extends HookConsumerWidget {
final pageInfoAsync = final pageInfoAsync =
(precachedInfo?.isPageInfoComplete ?? false) (precachedInfo?.isPageInfoComplete ?? false)
? AsyncValue.data(precachedInfo!) ? AsyncValue.data(precachedInfo!)
: ref.watch(pageInfoProvider(url)); : ref.watch(pageInfoProvider(url, isImageRequest: false));
return Skeletonizer( return Skeletonizer(
enabled: pageInfoAsync.isLoading && precachedInfo == null, enabled: pageInfoAsync.isLoading && precachedInfo == null,
child: pageInfoAsync.when( child: pageInfoAsync.when(
skipLoadingOnReload: true,
data: (info) { data: (info) {
return ListTile( return ListTile(
leading: RawImage( leading: RawImage(
@@ -36,7 +37,8 @@ class WebsiteTitleTile extends HookConsumerWidget {
error: (error, stackTrace) { error: (error, stackTrace) {
return FailureWidget( return FailureWidget(
title: error.toString(), title: error.toString(),
onRetry: () => ref.refresh(pageInfoProvider(url)), onRetry:
() => ref.refresh(pageInfoProvider(url, isImageRequest: false)),
); );
}, },
loading: loading:
+30
View File
@@ -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';
}
+4
View File
@@ -23,6 +23,10 @@ class LRUCache<K, V> {
_capacity = capacity; _capacity = capacity;
} }
bool contains(K key) {
return _cache.containsKey(key);
}
V? get(K key) { V? get(K key) {
final value = _cache.remove(key); // Temporarily remove the item. final value = _cache.remove(key); // Temporarily remove the item.
@@ -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: ["<all_urls>"] },
["blocking", "responseHeaders"]
);
@@ -2,20 +2,24 @@
"manifest_version": 2, "manifest_version": 2,
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "turndown@movenext.me" "id": "browser_extension@movenext.me"
} }
}, },
"name": "Converts html to markdown", "name": "Misc extensions",
"version": "1.0", "version": "1.0",
"background": { "background": {
"scripts": [ "scripts": [
"readability.min.js", "readability.min.js",
"background.js" "port.js",
"turndown.js",
"feed.js"
] ]
}, },
"permissions": [ "permissions": [
"geckoViewAddons", "geckoViewAddons",
"nativeMessaging", "nativeMessaging",
"webRequest",
"webRequestBlocking",
"<all_urls>" "<all_urls>"
] ]
} }
@@ -0,0 +1 @@
const port = browser.runtime.connectNative("mozacBrowserExtension");
@@ -1,4 +1,3 @@
const port = browser.runtime.connectNative("mozacTurndownHtml");
const parser = new DOMParser(); const parser = new DOMParser();
port.onMessage.addListener(message => { port.onMessage.addListener(message => {
@@ -15,6 +14,7 @@ port.onMessage.addListener(message => {
}); });
port.postMessage({ port.postMessage({
"type": "turndown",
"id": requestId, "id": requestId,
"status": "success", "status": "success",
"result": results "result": results
@@ -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.Search
import eu.lensai.flutter_mozilla_components.components.Services import eu.lensai.flutter_mozilla_components.components.Services
import eu.lensai.flutter_mozilla_components.components.UseCases 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.GeckoAddonEvents
import eu.lensai.flutter_mozilla_components.pigeons.GeckoStateEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoStateEvents
import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestionEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestionEvents
@@ -21,10 +22,11 @@ class Components(private val context: Context,
val flutterEvents: GeckoStateEvents, val flutterEvents: GeckoStateEvents,
val readerViewController: ReaderViewController, val readerViewController: ReaderViewController,
val selectionAction: SelectionActionDelegate, val selectionAction: SelectionActionDelegate,
val addonEvents: GeckoAddonEvents, private val addonEvents: GeckoAddonEvents,
val tabContentEvents: GeckoTabContentEvents 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 events by lazy { Events(flutterEvents) }
val useCases by lazy { UseCases(context, core.engine, core.store) } val useCases by lazy { UseCases(context, core.engine, core.store) }
val services by lazy { Services(context, useCases.tabsUseCases) } val services by lazy { Services(context, useCases.tabsUseCases) }
@@ -8,7 +8,8 @@ import android.content.Context
import eu.lensai.flutter_mozilla_components.feature.ContainerProxyFeature import eu.lensai.flutter_mozilla_components.feature.ContainerProxyFeature
import eu.lensai.flutter_mozilla_components.feature.CookieManagerFeature import eu.lensai.flutter_mozilla_components.feature.CookieManagerFeature
import eu.lensai.flutter_mozilla_components.feature.PrefManagerFeature 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.GeckoEngine
import mozilla.components.browser.engine.gecko.fetch.GeckoViewFetchClient import mozilla.components.browser.engine.gecko.fetch.GeckoViewFetchClient
import mozilla.components.concept.engine.DefaultSettings import mozilla.components.concept.engine.DefaultSettings
@@ -44,7 +45,7 @@ object EngineProvider {
return runtime!! return runtime!!
} }
fun createEngine(context: Context, defaultSettings: DefaultSettings): Engine { fun createEngine(context: Context, defaultSettings: DefaultSettings, extensionEvents: BrowserExtensionEvents): Engine {
Logger.debug("Creating Engine") Logger.debug("Creating Engine")
val runtime = getOrCreateRuntime(context) val runtime = getOrCreateRuntime(context)
@@ -53,7 +54,7 @@ object EngineProvider {
CookieManagerFeature.install(it) CookieManagerFeature.install(it)
PrefManagerFeature.install(it) PrefManagerFeature.install(it)
ContainerProxyFeature.install(it) ContainerProxyFeature.install(it)
TurndownFeature.install(it) BrowserExtensionFeature.install(it, extensionEvents)
} }
} }
@@ -2,6 +2,7 @@ package eu.lensai.flutter_mozilla_components
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.view.View
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import eu.lensai.flutter_mozilla_components.activities.NotificationActivity import eu.lensai.flutter_mozilla_components.activities.NotificationActivity
import eu.lensai.flutter_mozilla_components.api.GeckoAddonsApiImpl 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.GeckoSessionApiImpl
import eu.lensai.flutter_mozilla_components.api.GeckoSuggestionApiImpl import eu.lensai.flutter_mozilla_components.api.GeckoSuggestionApiImpl
import eu.lensai.flutter_mozilla_components.api.GeckoTabsApiImpl 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.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.GeckoAddonEvents
import eu.lensai.flutter_mozilla_components.pigeons.GeckoAddonsApi import eu.lensai.flutter_mozilla_components.pigeons.GeckoAddonsApi
import eu.lensai.flutter_mozilla_components.pigeons.GeckoBrowserApi 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.GeckoContainerProxyApi
import eu.lensai.flutter_mozilla_components.pigeons.GeckoCookieApi import eu.lensai.flutter_mozilla_components.pigeons.GeckoCookieApi
import eu.lensai.flutter_mozilla_components.pigeons.GeckoDeleteBrowsingDataController 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.GeckoSuggestionEvents
import eu.lensai.flutter_mozilla_components.pigeons.GeckoTabContentEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoTabContentEvents
import eu.lensai.flutter_mozilla_components.pigeons.GeckoTabsApi 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.ReaderViewController
import eu.lensai.flutter_mozilla_components.pigeons.ReaderViewEvents import eu.lensai.flutter_mozilla_components.pigeons.ReaderViewEvents
@@ -68,7 +70,6 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
private lateinit var _flutterEvents : GeckoStateEvents private lateinit var _flutterEvents : GeckoStateEvents
private var isPlatformViewRegistered = false private var isPlatformViewRegistered = false
private var pendingFragmentShow = false
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
synchronized(this) { synchronized(this) {
@@ -92,6 +93,8 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
val readerViewController = val readerViewController =
ReaderViewController(_flutterPluginBinding.binaryMessenger) ReaderViewController(_flutterPluginBinding.binaryMessenger)
val extensionEvents = BrowserExtensionEvents(_flutterPluginBinding.binaryMessenger)
val addonEvents = GeckoAddonEvents(_flutterPluginBinding.binaryMessenger) val addonEvents = GeckoAddonEvents(_flutterPluginBinding.binaryMessenger)
val tabContentEvents = GeckoTabContentEvents(_flutterPluginBinding.binaryMessenger) val tabContentEvents = GeckoTabContentEvents(_flutterPluginBinding.binaryMessenger)
@@ -105,6 +108,7 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
selectionActionDelegate, selectionActionDelegate,
addonEvents, addonEvents,
tabContentEvents, tabContentEvents,
extensionEvents
) )
GeckoBrowserApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoBrowserApiImpl { GeckoBrowserApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoBrowserApiImpl {
@@ -125,7 +129,7 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
)) ))
GeckoDeleteBrowsingDataController.setUp(_flutterPluginBinding.binaryMessenger, GeckoDeleteBrowsingDataControllerImpl()) GeckoDeleteBrowsingDataController.setUp(_flutterPluginBinding.binaryMessenger, GeckoDeleteBrowsingDataControllerImpl())
GeckoDownloadsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoDownloadsApiImpl()) GeckoDownloadsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoDownloadsApiImpl())
GeckoTurndownApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTurndownApiImpl()) GeckoBrowserExtensionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoBrowserExtensionApiImpl())
ReaderViewEvents.setUp( ReaderViewEvents.setUp(
_flutterPluginBinding.binaryMessenger, _flutterPluginBinding.binaryMessenger,
@@ -137,21 +141,31 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
flutterPluginBinding.applicationContext.startActivity(intent) flutterPluginBinding.applicationContext.startActivity(intent)
} }
private fun showNativeFragment() { private fun showNativeFragment(): Boolean {
if (!isPlatformViewRegistered) { if (!isPlatformViewRegistered) {
pendingFragmentShow = true return false
return
} }
if (activity == null) { if (activity == null || activity !is FragmentActivity) {
return return false
}
val fragmentActivity = activity as FragmentActivity
// Check if the container view exists in the view hierarchy
val container = fragmentActivity.findViewById<View>(FRAGMENT_CONTAINER_ID)
if (container == null) {
// Container doesn't exist yet, retry later
return false
} }
val nativeFragment = BrowserFragment.create() val nativeFragment = BrowserFragment.create()
val fm = (activity as FragmentActivity).supportFragmentManager val fm = fragmentActivity.supportFragmentManager
fm.beginTransaction() fm.beginTransaction()
.replace(FRAGMENT_CONTAINER_ID, nativeFragment) .replace(FRAGMENT_CONTAINER_ID, nativeFragment)
.commitAllowingStateLoss() .commitAllowingStateLoss()
return true
} }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -170,12 +184,6 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
) )
isPlatformViewRegistered = true isPlatformViewRegistered = true
// Process any pending fragment show request
if (pendingFragmentShow) {
pendingFragmentShow = false
showNativeFragment()
}
} }
override fun onDetachedFromActivityForConfigChanges() { override fun onDetachedFromActivityForConfigChanges() {
@@ -189,6 +197,5 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
override fun onDetachedFromActivity() { override fun onDetachedFromActivity() {
this.activity = null this.activity = null
isPlatformViewRegistered = false isPlatformViewRegistered = false
pendingFragmentShow = false
} }
} }
@@ -37,7 +37,13 @@ private class NativeFragmentView(
FrameLayout.LayoutParams( FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT 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.layoutParams = vParams
container.id = containerId container.id = containerId
} }
@@ -45,7 +51,7 @@ private class NativeFragmentView(
override fun onFlutterViewAttached(flutterView: View) { override fun onFlutterViewAttached(flutterView: View) {
super.onFlutterViewAttached(flutterView) super.onFlutterViewAttached(flutterView)
components.engineReportedInitialized = false; components.engineReportedInitialized = false
flutterEvents.onViewReadyStateChange(System.currentTimeMillis(), true) { _ -> } flutterEvents.onViewReadyStateChange(System.currentTimeMillis(), true) { _ -> }
} }
@@ -1,6 +1,7 @@
package eu.lensai.flutter_mozilla_components package eu.lensai.flutter_mozilla_components
import android.content.Context 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.GeckoAddonEvents
import eu.lensai.flutter_mozilla_components.pigeons.GeckoStateEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoStateEvents
import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestionEvents import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestionEvents
@@ -44,7 +45,8 @@ object GlobalComponents {
readerViewController: ReaderViewController, readerViewController: ReaderViewController,
selectionAction: SelectionActionDelegate, selectionAction: SelectionActionDelegate,
addonEvents: GeckoAddonEvents, addonEvents: GeckoAddonEvents,
tabContentEvents: GeckoTabContentEvents tabContentEvents: GeckoTabContentEvents,
extensionEvents: BrowserExtensionEvents
) { ) {
Logger.debug("Creating new components") Logger.debug("Creating new components")
@@ -54,7 +56,8 @@ object GlobalComponents {
readerViewController, readerViewController,
selectionAction, selectionAction,
addonEvents, addonEvents,
tabContentEvents tabContentEvents,
extensionEvents
) )
//newComponents.crashReporter.install(applicationContext) //newComponents.crashReporter.install(applicationContext)
@@ -9,17 +9,19 @@ import mozilla.components.feature.addons.logger
* Implementation of GeckoBrowserApi that handles browser-related operations * Implementation of GeckoBrowserApi that handles browser-related operations
* @param showFragmentCallback Callback function to show native fragment * @param showFragmentCallback Callback function to show native fragment
*/ */
class GeckoBrowserApiImpl(private val showFragmentCallback: () -> Unit) : GeckoBrowserApi { class GeckoBrowserApiImpl(private val showFragmentCallback: () -> Boolean) : GeckoBrowserApi {
companion object { companion object {
private const val TAG = "GeckoBrowserApiImpl" private const val TAG = "GeckoBrowserApiImpl"
} }
override fun showNativeFragment() { override fun showNativeFragment(): Boolean {
try { try {
showFragmentCallback() return showFragmentCallback()
} catch (e: Exception) { } catch (e: Exception) {
logger.error("Failed to show native fragment", e) logger.error("Failed to show native fragment", e)
} }
return false
} }
override fun onTrimMemory(level: Long) { override fun onTrimMemory(level: Long) {
@@ -1,12 +1,12 @@
package eu.lensai.flutter_mozilla_components.api package eu.lensai.flutter_mozilla_components.api
import eu.lensai.flutter_mozilla_components.feature.ResultConsumer import eu.lensai.flutter_mozilla_components.feature.ResultConsumer
import eu.lensai.flutter_mozilla_components.feature.TurndownFeature import eu.lensai.flutter_mozilla_components.feature.BrowserExtensionFeature
import eu.lensai.flutter_mozilla_components.pigeons.GeckoTurndownApi import eu.lensai.flutter_mozilla_components.pigeons.GeckoBrowserExtensionApi
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
class GeckoTurndownApiImpl : GeckoTurndownApi { class GeckoBrowserExtensionApiImpl : GeckoBrowserExtensionApi {
private fun JSONObject.toMap(): Map<String, Any> { private fun JSONObject.toMap(): Map<String, Any> {
val map = mutableMapOf<String, Any>() val map = mutableMapOf<String, Any>()
val keys = this.keys() val keys = this.keys()
@@ -38,7 +38,7 @@ class GeckoTurndownApiImpl : GeckoTurndownApi {
} }
override fun getMarkdown(htmlList: List<String>, callback: (Result<List<Any>>) -> Unit) { override fun getMarkdown(htmlList: List<String>, callback: (Result<List<Any>>) -> Unit) {
TurndownFeature.scheduleRequest("turndown", htmlList, object : BrowserExtensionFeature.scheduleRequest("turndown", htmlList, object :
ResultConsumer<JSONObject> { ResultConsumer<JSONObject> {
override fun success(result: JSONObject) { override fun success(result: JSONObject) {
val resultArray = result.getJSONArray("result") val resultArray = result.getJSONArray("result")
@@ -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.R
import eu.lensai.flutter_mozilla_components.ext.getPreferenceKey import eu.lensai.flutter_mozilla_components.ext.getPreferenceKey
import eu.lensai.flutter_mozilla_components.middleware.FlutterEventMiddleware 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 eu.lensai.flutter_mozilla_components.pigeons.GeckoStateEvents
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import mozilla.components.browser.engine.gecko.permission.GeckoSitePermissionsStorage 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, class Core(private val context: Context,
private val components: Components, private val components: Components,
private val flutterEvents: GeckoStateEvents, private val flutterEvents: GeckoStateEvents,
private val extensionEvents: BrowserExtensionEvents
) { ) {
val prefs by lazy { val prefs by lazy {
PreferenceManager.getDefaultSharedPreferences(context) PreferenceManager.getDefaultSharedPreferences(context)
@@ -95,7 +97,7 @@ class Core(private val context: Context,
} }
val engine: Engine by lazy { val engine: Engine by lazy {
EngineProvider.createEngine(context, engineSettings) EngineProvider.createEngine(context, engineSettings, extensionEvents)
} }
/** /**
@@ -1,6 +1,7 @@
package eu.lensai.flutter_mozilla_components.feature package eu.lensai.flutter_mozilla_components.feature
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import eu.lensai.flutter_mozilla_components.pigeons.BrowserExtensionEvents
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -13,13 +14,15 @@ import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.webextensions.WebExtensionController import mozilla.components.support.webextensions.WebExtensionController
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.mozilla.gecko.util.ThreadUtils.runOnUiThread
object TurndownFeature { object BrowserExtensionFeature {
private val logger = Logger("turndown") private val logger = Logger("browser_extension")
private const val PREF_MANAGER_REPORTER_EXTENSION_ID = "turndown@movenext.me" private const val PREF_MANAGER_REPORTER_EXTENSION_ID = "browser_extension@movenext.me"
private const val PREF_MANAGER_REPORTER_EXTENSION_URL = "resource://android/assets/extensions/turndown/" private const val PREF_MANAGER_REPORTER_EXTENSION_URL =
private const val PREF_MANAGER_REPORTER_MESSAGING_ID = "mozacTurndownHtml" "resource://android/assets/extensions/browser_extension/"
private const val PREF_MANAGER_REPORTER_MESSAGING_ID = "mozacBrowserExtension"
private var nextRequestId: Int = 0 private var nextRequestId: Int = 0
private val requestHandlers = HashMap<Int, ResultConsumer<JSONObject>>() private val requestHandlers = HashMap<Int, ResultConsumer<JSONObject>>()
@@ -30,16 +33,22 @@ object TurndownFeature {
internal var extensionController = WebExtensionController( internal var extensionController = WebExtensionController(
PREF_MANAGER_REPORTER_EXTENSION_ID, PREF_MANAGER_REPORTER_EXTENSION_ID,
PREF_MANAGER_REPORTER_EXTENSION_URL, PREF_MANAGER_REPORTER_EXTENSION_URL,
PREF_MANAGER_REPORTER_MESSAGING_ID, PREF_MANAGER_REPORTER_MESSAGING_ID
) )
fun scheduleRequest(command: String, args: Any, callback: ResultConsumer<JSONObject>) { fun scheduleRequest(
command: String,
args: Any,
callback: ResultConsumer<JSONObject>
) {
val message = JSONObject() val message = JSONObject()
message.put("action", command); message.put("action", command);
message.put("args", when (args) { message.put(
"args", when (args) {
is List<*> -> JSONArray(args) is List<*> -> JSONArray(args)
else -> args else -> args
}) }
)
runBlocking { runBlocking {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@@ -56,13 +65,26 @@ object TurndownFeature {
} }
} }
private class TurndownBackgroundMessageHandler() : MessageHandler { private class ExtensionBackgroundMessageHandler(
private val extensionEvents: BrowserExtensionEvents
) : MessageHandler {
override fun onPortMessage(message: Any, port: Port) { override fun onPortMessage(message: Any, port: Port) {
runBlocking { runBlocking {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
mutex.withLock { mutex.withLock {
val messageJSON = message as JSONObject; val messageJSON = message as JSONObject;
val type = messageJSON.getString("type")
if (type == "feedRequest") {
val url = messageJSON.getString("url")
runOnUiThread {
extensionEvents.onFeedRequested(
System.currentTimeMillis(),
url
) { _ -> }
}
} else if (type == "turndown") {
val requestId = messageJSON.getInt("id") val requestId = messageJSON.getInt("id")
val status = messageJSON.getString("status") val status = messageJSON.getString("status")
if (status == "success") { if (status == "success") {
@@ -79,6 +101,7 @@ object TurndownFeature {
} }
} }
} }
}
/** /**
* Installs the web extension in the runtime through the WebExtensionRuntime install method * Installs the web extension in the runtime through the WebExtensionRuntime install method
@@ -87,17 +110,17 @@ object TurndownFeature {
* @param productName a custom product name used to automatically label reports. Defaults to * @param productName a custom product name used to automatically label reports. Defaults to
* "android-components". * "android-components".
*/ */
fun install(runtime: WebExtensionRuntime) { fun install(runtime: WebExtensionRuntime, extensionEvents: BrowserExtensionEvents) {
extensionController.registerBackgroundMessageHandler( extensionController.registerBackgroundMessageHandler(
TurndownBackgroundMessageHandler(), ExtensionBackgroundMessageHandler(extensionEvents)
) )
extensionController.install( extensionController.install(
runtime, runtime,
onSuccess = { onSuccess = {
logger.debug("Installed Turndown webextension: ${it.id}") logger.debug("Installed browser_extension webextension: ${it.id}")
}, },
onError = { throwable -> onError = { throwable ->
logger.error("Failed to install Turndown webextension: ", throwable) logger.error("Failed to install browser_extension webextension: ", throwable)
}, },
) )
} }
@@ -1994,7 +1994,7 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface GeckoBrowserApi { interface GeckoBrowserApi {
fun showNativeFragment() fun showNativeFragment(): Boolean
fun onTrimMemory(level: Long) fun onTrimMemory(level: Long)
companion object { companion object {
@@ -2011,8 +2011,7 @@ interface GeckoBrowserApi {
if (api != null) { if (api != null) {
channel.setMessageHandler { _, reply -> channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try { val wrapped: List<Any?> = try {
api.showNativeFragment() listOf(api.showNativeFragment())
listOf(null)
} catch (exception: Throwable) { } catch (exception: Throwable) {
wrapError(exception) wrapError(exception)
} }
@@ -2980,20 +2979,20 @@ interface GeckoPrefApi {
} }
} }
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface GeckoTurndownApi { interface GeckoBrowserExtensionApi {
fun getMarkdown(htmlList: List<String>, callback: (Result<List<Any>>) -> Unit) fun getMarkdown(htmlList: List<String>, callback: (Result<List<Any>>) -> Unit)
companion object { companion object {
/** The codec used by GeckoTurndownApi. */ /** The codec used by GeckoBrowserExtensionApi. */
val codec: MessageCodec<Any?> by lazy { val codec: MessageCodec<Any?> by lazy {
GeckoPigeonCodec() 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 @JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: GeckoTurndownApi?, messageChannelSuffix: String = "") { fun setUp(binaryMessenger: BinaryMessenger, api: GeckoBrowserExtensionApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTurndownApi.getMarkdown$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserExtensionApi.getMarkdown$separatedMessageChannelSuffix", codec)
if (api != null) { if (api != null) {
channel.setMessageHandler { message, reply -> channel.setMessageHandler { message, reply ->
val args = message as List<Any?> val args = message as List<Any?>
@@ -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<Any?> by lazy {
GeckoPigeonCodec()
}
}
fun onFeedRequested(timestampArg: Long, urlArg: String, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.BrowserExtensionEvents.onFeedRequested$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(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)))
}
}
}
}
@@ -18,7 +18,7 @@ export 'src/domain/services/gecko_session.dart';
export 'src/domain/services/gecko_suggestions.dart'; export 'src/domain/services/gecko_suggestions.dart';
export 'src/domain/services/gecko_tab.dart'; export 'src/domain/services/gecko_tab.dart';
export 'src/domain/services/gecko_tab_content.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/geckoview_widget.dart';
export 'src/pigeons/gecko.g.dart' export 'src/pigeons/gecko.g.dart'
show show
@@ -7,7 +7,7 @@ class GeckoBrowserService {
GeckoBrowserService({GeckoBrowserApi? api}) : _api = api ?? _apiInstance; GeckoBrowserService({GeckoBrowserApi? api}) : _api = api ?? _apiInstance;
Future<void> showNativeFragment() { Future<bool> showNativeFragment() {
return _api.showNativeFragment(); return _api.showNativeFragment();
} }
@@ -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<String>();
Stream<String> get feedRequested => _feedRequest.stream;
static Future<List<TurndownResults>> turndownHtml(
List<String> 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());
}
}
@@ -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<List<TurndownResults>> turndownHtml(List<String> 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;
}
}

Some files were not shown because too many files have changed in this diff Show More