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