From 0a7e84236f067dbcb52a3f0bc6ac1ffb0cb9cfe6 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Wed, 28 Aug 2024 16:00:39 +0200 Subject: [PATCH] improved tab & topic handling --- .../domain/services/app_initialization.dart | 3 + .../domain/services/app_initialization.g.dart | 2 +- .../search_browser/domain/providers.dart | 68 ++++ .../search_browser/domain/providers.g.dart | 17 + .../presentation/dialogs/tab_action.dart | 92 ++++++ .../presentation/screens/browser.dart | 300 +++++++++-------- .../widgets/sheets/view_tabs_sheet.dart | 220 ++++++++----- .../widgets/tabs_action_button.dart | 4 +- .../topics/data/database/daos/tab.dart | 52 ++- .../topics/data/database/daos/topic.dart | 1 + .../topics/data/database/database.dart | 1 + .../topics/data/database/database.drift | 19 +- .../topics/data/database/database.g.dart | 86 +---- .../data/database/drift/converters/uri.dart | 2 + .../topics/data/models/topic_data.dart | 41 +++ app/lib/features/topics/data/providers.dart | 12 + app/lib/features/topics/data/providers.g.dart | 275 ++++++++++++++++ app/lib/features/topics/domain/providers.dart | 28 +- .../features/topics/domain/providers.g.dart | 35 +- .../topics/domain/repositories/tab.dart | 46 ++- .../topics/domain/repositories/tab.g.dart | 173 +++++++++- .../topics/domain/repositories/topic.dart | 16 +- .../topics/domain/repositories/topic.g.dart | 6 +- .../presentation/screens/topic_list.dart | 65 ++-- .../presentation/widgets/topic_chips.dart | 5 +- .../domain/entities/web_view_page.dart | 15 +- .../domain/entities/web_view_page.g.dart | 137 ++++++++ .../features/web_view/domain/providers.dart | 11 + .../features/web_view/domain/providers.g.dart | 14 + .../domain/repositories/web_view.dart | 198 ++++++++--- .../domain/repositories/web_view.g.dart | 309 +++++++++++++++++- .../controllers/readerability.dart | 26 +- .../controllers/readerability.g.dart | 40 ++- .../controllers/switch_new_tab.dart | 13 +- .../controllers/switch_new_tab.g.dart | 6 +- .../services/readerability_script.dart | 6 +- .../services/readerability_script.g.dart | 40 ++- .../presentation/widgets/web_page_dialog.dart | 68 +--- .../presentation/widgets/web_view.dart | 253 ++++---------- .../presentation/widgets/web_view_tab.dart | 31 +- .../widgets/selectable_chips.dart | 89 +++-- .../widgets/website_title_tile.dart | 31 +- app/pubspec.lock | 26 +- 43 files changed, 2035 insertions(+), 847 deletions(-) create mode 100644 app/lib/features/search_browser/presentation/dialogs/tab_action.dart create mode 100644 app/lib/features/topics/data/models/topic_data.dart create mode 100644 app/lib/features/web_view/domain/entities/web_view_page.g.dart diff --git a/app/lib/domain/services/app_initialization.dart b/app/lib/domain/services/app_initialization.dart index 589aec93..30e22a2c 100644 --- a/app/lib/domain/services/app_initialization.dart +++ b/app/lib/domain/services/app_initialization.dart @@ -9,6 +9,7 @@ import 'package:lensai/features/content_block/domain/repositories/sync.dart'; import 'package:lensai/features/search_browser/domain/services/session.dart'; import 'package:lensai/features/settings/data/models/settings.dart'; import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; +import 'package:lensai/features/topics/data/providers.dart'; import 'package:lensai/features/web_view/domain/providers.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -79,6 +80,8 @@ class AppInitializationService extends _$AppInitializationService { if (settings.incognitoMode) { await ref.read(sessionServiceProvider.notifier).clearAllData(); + //Delete all unsasigned tabs + await ref.read(tabDatabaseProvider).tabDao.deleteTopicTabs(null); } if (settings.kagiSession case final String session) { diff --git a/app/lib/domain/services/app_initialization.g.dart b/app/lib/domain/services/app_initialization.g.dart index 7a31a38a..809183f6 100644 --- a/app/lib/domain/services/app_initialization.g.dart +++ b/app/lib/domain/services/app_initialization.g.dart @@ -7,7 +7,7 @@ part of 'app_initialization.dart'; // ************************************************************************** String _$appInitializationServiceHash() => - r'19ec598e2141dd306eee61c1f9edd7f515b56738'; + r'dadcf205d32efa61eb2a0ff1c9b3d49658b76a56'; /// See also [AppInitializationService]. @ProviderFor(AppInitializationService) diff --git a/app/lib/features/search_browser/domain/providers.dart b/app/lib/features/search_browser/domain/providers.dart index 6773bc03..818a9044 100644 --- a/app/lib/features/search_browser/domain/providers.dart +++ b/app/lib/features/search_browser/domain/providers.dart @@ -2,12 +2,18 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart'; import 'package:lensai/features/bangs/domain/repositories/data.dart'; import 'package:lensai/features/search_browser/domain/entities/modes.dart'; import 'package:lensai/features/search_browser/domain/entities/sheet.dart'; import 'package:lensai/features/search_browser/domain/services/create_tab.dart'; +import 'package:lensai/features/topics/data/database/database.dart'; +import 'package:lensai/features/topics/domain/repositories/tab.dart'; +import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; +import 'package:lensai/features/web_view/presentation/widgets/web_view.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:rxdart/rxdart.dart'; @@ -137,3 +143,65 @@ class ShowFindInPage extends _$ShowFindInPage { return false; } } + +@Riverpod() +class WebViewWidgetCache extends _$WebViewWidgetCache { + final _requestedWidgets = {}; + + List _availableTabs = []; + + @override + List build() { + ref.listen( + tabRepositoryProvider, + (previous, next) { + if (next.hasValue && next.valueOrNull != null) { + _availableTabs = next.valueOrNull!; + + _removeClosedWidgets(); + _requestedWidgets.removeWhere(_ensureWidgetCached); + } + }, + ); + + ref.listen( + webViewTabControllerProvider, + (_, tabId) { + if (tabId != null && !_ensureWidgetCached(tabId)) { + _requestedWidgets.add(tabId); + } + }, + ); + + return stateOrNull ?? []; + } + + void _removeClosedWidgets() { + state = [...state]..removeWhere( + (element) => !_availableTabs.any((tab) => tab.id == element.tabId), + ); + } + + bool _ensureWidgetCached(String tabId) { + final index = state.indexWhere((tab) => tab.tabId == tabId); + if (index < 0) { + final tab = _availableTabs.firstWhereOrNull((tab) => tab.id == tabId); + if (tab != null) { + state = [ + ...state, + WebView( + key: GlobalKey(), + tabId: tabId, + initialUrlRequest: URLRequest(url: WebUri.uri(tab.url)), + ), + ]; + + return true; + } + + return false; + } + + return true; + } +} diff --git a/app/lib/features/search_browser/domain/providers.g.dart b/app/lib/features/search_browser/domain/providers.g.dart index 0c2619d3..877ca964 100644 --- a/app/lib/features/search_browser/domain/providers.g.dart +++ b/app/lib/features/search_browser/domain/providers.g.dart @@ -411,5 +411,22 @@ final showFindInPageProvider = NotifierProvider.internal( ); typedef _$ShowFindInPage = Notifier; +String _$webViewWidgetCacheHash() => + r'7d6f769e8c314d9dc579d700d36d6d4c944e600f'; + +/// See also [WebViewWidgetCache]. +@ProviderFor(WebViewWidgetCache) +final webViewWidgetCacheProvider = + AutoDisposeNotifierProvider>.internal( + WebViewWidgetCache.new, + name: r'webViewWidgetCacheProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$webViewWidgetCacheHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$WebViewWidgetCache = AutoDisposeNotifier>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/search_browser/presentation/dialogs/tab_action.dart b/app/lib/features/search_browser/presentation/dialogs/tab_action.dart new file mode 100644 index 00000000..eee12fa1 --- /dev/null +++ b/app/lib/features/search_browser/presentation/dialogs/tab_action.dart @@ -0,0 +1,92 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/features/topics/domain/repositories/topic.dart'; +import 'package:lensai/features/web_view/domain/entities/web_view_page.dart'; +import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; +import 'package:lensai/features/web_view/presentation/widgets/favicon.dart'; + +class TabActionDialog extends HookConsumerWidget { + final WebViewPage initialTab; + + final void Function()? onDismiss; + + const TabActionDialog({ + required this.initialTab, + this.onDismiss, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tabState = ref.watch(tabStateProvider(initialTab.id)) ?? initialTab; + final topics = + ref.watch(topicRepositoryProvider.select((value) => value.valueOrNull)); + final selectedTopic = + topics?.firstWhereOrNull((topic) => topic.id == tabState.topicId); + + final expansionController = useExpansionTileController(); + + return Stack( + children: [ + ModalBarrier( + color: Theme.of(context).dialogTheme.barrierColor ?? Colors.black54, + onDismiss: onDismiss, + ), + SimpleDialog( + titlePadding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0), + contentPadding: EdgeInsets.zero, + insetPadding: const EdgeInsets.symmetric( + horizontal: 20.0, + vertical: 24.0, + ), + title: ListTile( + leading: FaviconImage( + favicon: tabState.favicon, + url: tabState.url, + size: 24, + ), + contentPadding: EdgeInsets.zero, + title: Text(tabState.title ?? 'Unknown Title'), + subtitle: Text(tabState.url.authority), + ), + children: [ + SizedBox( + //We need this to stretch the dialog, then padding from dialog is applied + width: double.maxFinite, + child: ExpansionTile( + controller: expansionController, + leading: (selectedTopic != null) + ? CircleAvatar(backgroundColor: selectedTopic.color) + : null, + title: (selectedTopic != null) + ? Text(selectedTopic.name ?? 'New Topic') + : const Text('Assign a Topic'), + children: topics + ?.where((topic) => topic.id != tabState.topicId) + .map( + (topic) => ListTile( + leading: CircleAvatar(backgroundColor: topic.color), + title: Text(topic.name ?? 'New Topic'), + onTap: () async { + await ref + .read( + tabStateProvider(initialTab.id).notifier, + ) + .copyWith(topicId: topic.id); + + expansionController.collapse(); + }, + ), + ) + .toList() ?? + [], + ), + ), + ], + ), + ], + ); + } +} diff --git a/app/lib/features/search_browser/presentation/screens/browser.dart b/app/lib/features/search_browser/presentation/screens/browser.dart index b87b4801..49cf2575 100644 --- a/app/lib/features/search_browser/presentation/screens/browser.dart +++ b/app/lib/features/search_browser/presentation/screens/browser.dart @@ -19,7 +19,7 @@ import 'package:lensai/features/search_browser/presentation/widgets/sheets/view_ import 'package:lensai/features/search_browser/presentation/widgets/tabs_action_button.dart'; import 'package:lensai/features/settings/data/models/settings.dart'; import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/web_view/domain/entities/consistent_controller.dart'; +import 'package:lensai/features/topics/domain/repositories/tab.dart'; import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; import 'package:lensai/features/web_view/presentation/controllers/readerability.dart'; import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart'; @@ -64,7 +64,7 @@ class KagiScreen extends HookConsumerWidget { ), ); - final activeWebView = ref.watch(webViewTabControllerProvider); + final activeTabId = ref.watch(webViewTabControllerProvider); final menuController = useMemoized(() => MenuController()); @@ -102,21 +102,28 @@ class KagiScreen extends HookConsumerWidget { child: AppBar( automaticallyImplyLeading: false, titleSpacing: 8.0, - title: (activeWebView != null) - ? AppBarTitle( - activeWebView: activeWebView, - onTap: () { - final page = activeWebView.page.value; + title: (activeTabId != null) + ? Consumer( + builder: (context, ref, child) { + final page = ref.watch(tabStateProvider(activeTabId)); - ref.read(overlayDialogProvider.notifier).show( - WebPageDialog( + return (page != null) + ? AppBarTitle( page: page, - webViewController: page.controller, - onDismiss: ref - .read(overlayDialogProvider.notifier) - .dismiss, - ), - ); + onTap: () { + ref.read(overlayDialogProvider.notifier).show( + WebPageDialog( + url: page.url, + precachedInfo: page, + webViewController: page.controller, + onDismiss: ref + .read(overlayDialogProvider.notifier) + .dismiss, + ), + ); + }, + ) + : const SizedBox.shrink(); }, ) : HookBuilder( @@ -181,17 +188,13 @@ class KagiScreen extends HookConsumerWidget { }, ), actions: [ - if (activeWebView != null) + if (activeTabId != null) HookConsumer( builder: (context, ref, child) { final colorScheme = Theme.of(context).colorScheme; - final controller = - useListenable(activeWebView.page).value.controller; final readerabilityState = ref.watch( - readerabilityControllerProvider( - ConsistentController(controller), - ), + readerabilityControllerProvider(activeTabId), ); final enableReadability = ref.watch( @@ -231,24 +234,25 @@ class KagiScreen extends HookConsumerWidget { data: (_) => Visibility( visible: isReaderable == true || readerableApplied, child: InkWell( - onTap: readerabilityState.isLoading - ? null - : () async { - final controller = - activeWebView.currentController; + onTap: readerabilityState.isLoading + ? null + : () async { + final controller = ref.read( + webViewControllerProvider(activeTabId), + ); - if (controller != null) { - final readabilityNotifier = ref.read( - readerabilityControllerProvider( - ConsistentController(controller), - ).notifier, - ); + if (controller != null) { + final readabilityNotifier = ref.read( + readerabilityControllerProvider( + activeTabId, + ).notifier, + ); - await readabilityNotifier - .toggleReaderable(); - } - }, - child: icon, + await readabilityNotifier + .toggleReaderable(); + } + }, + child: icon, ), ), error: (error, stackTrace) => SizedBox.shrink(), @@ -271,7 +275,7 @@ class KagiScreen extends HookConsumerWidget { ); }, ), - if (activeWebView != null && quickAction != null) + if (quickAction != null) InkWell( onTap: () async { var tab = CreateTab( @@ -408,86 +412,106 @@ class KagiScreen extends HookConsumerWidget { child: const Text('Search'), ), const Divider(), - MenuItemButton( - onPressed: () async { - final url = - await activeWebView?.currentController?.getUrl(); - if (url != null) { - // ignore: use_build_context_synchronously - await ui_helper.launchUrlFeedback(context, url); - } - }, - leadingIcon: const Icon(Icons.open_in_browser), - child: const Text('Launch External'), - ), - MenuItemButton( - onPressed: () async { - final url = - await activeWebView?.currentController?.getUrl(); - if (url != null) { - await Share.shareUri(url); - } - }, - leadingIcon: const Icon(Icons.share), - child: const Text('Share'), - ), - const Divider(), - MenuItemButton( - onPressed: () async { - ref.read(showFindInPageProvider.notifier).update(true); - }, - leadingIcon: const Icon(Icons.search), - child: const Text('Find in page'), - ), - const Divider(), - MenuItemButton( - onPressed: () async { - await activeWebView?.currentController?.reload(); - }, - leadingIcon: const Icon(Icons.refresh), - child: const Text('Reload'), - ), - const Divider(), - HookBuilder( - builder: (context) { - final history = useListenableSelector( - activeWebView?.page, - () => - activeWebView?.page.value.pageHistory ?? - (canGoBack: false, canGoForward: false), - ); + if (activeTabId != null) + MenuItemButton( + onPressed: () async { + final controller = + ref.read(webViewControllerProvider(activeTabId)); - return Row( - children: [ - Expanded( - child: IconButton( - onPressed: (history.canGoBack) - ? () async { - await activeWebView?.currentController - ?.goBack(); - menuController.close(); - } - : null, - icon: const Icon(Icons.arrow_back), + final url = await controller?.getUrl(); + if (url != null) { + // ignore: use_build_context_synchronously + await ui_helper.launchUrlFeedback(context, url); + } + }, + leadingIcon: const Icon(Icons.open_in_browser), + child: const Text('Launch External'), + ), + if (activeTabId != null) + MenuItemButton( + onPressed: () async { + final controller = + ref.read(webViewControllerProvider(activeTabId)); + + final url = await controller?.getUrl(); + if (url != null) { + await Share.shareUri(url); + } + }, + leadingIcon: const Icon(Icons.share), + child: const Text('Share'), + ), + if (activeTabId != null) const Divider(), + if (activeTabId != null) + MenuItemButton( + onPressed: () async { + ref.read(showFindInPageProvider.notifier).update(true); + }, + leadingIcon: const Icon(Icons.search), + child: const Text('Find in page'), + ), + if (activeTabId != null) const Divider(), + if (activeTabId != null) + MenuItemButton( + onPressed: () async { + final controller = + ref.read(webViewControllerProvider(activeTabId)); + + await controller?.reload(); + }, + leadingIcon: const Icon(Icons.refresh), + child: const Text('Reload'), + ), + if (activeTabId != null) const Divider(), + if (activeTabId != null) + Consumer( + builder: (context, ref, child) { + final history = ref.watch( + tabStateProvider(activeTabId) + .select((value) => value?.pageHistory), + ); + + return Row( + children: [ + Expanded( + child: IconButton( + onPressed: (history?.canGoBack == true) + ? () async { + final controller = ref.read( + webViewControllerProvider( + activeTabId, + ), + ); + + await controller?.goBack(); + menuController.close(); + } + : null, + icon: const Icon(Icons.arrow_back), + ), ), - ), - const SizedBox(height: 48, child: VerticalDivider()), - Expanded( - child: IconButton( - onPressed: (history.canGoForward) - ? () async { - await activeWebView?.currentController - ?.goForward(); - menuController.close(); - } - : null, - icon: const Icon(Icons.arrow_forward), + const SizedBox(height: 48, child: VerticalDivider()), + Expanded( + child: IconButton( + onPressed: (history?.canGoForward == true) + ? () async { + final controller = ref.read( + webViewControllerProvider( + activeTabId, + ), + ); + + await controller?.goForward(); + menuController.close(); + } + : null, + icon: const Icon(Icons.arrow_forward), + ), ), - ), - ], - ); - }, - ), + ], + ); + }, + ), ], ), ], @@ -506,10 +530,18 @@ class KagiScreen extends HookConsumerWidget { : null, child: HookConsumer( builder: (context, ref, child) { - final webViews = ref.watch(webViewRepositoryProvider); + final webViewWidgets = ref.watch(webViewWidgetCacheProvider); + final activeWebViewStackIndex = (activeTabId != null) + ? webViewWidgets + .indexWhere((view) => view.tabId == activeTabId) + : null; return BackButtonListener( onBackButtonPressed: () async { + final page = (activeTabId != null) + ? ref.read(tabStateProvider(activeTabId)) + : null; + //Don't do anything if a child route is active if (GoRouterState.of(context).topRoute?.name != 'KagiRoute') { return false; @@ -525,10 +557,10 @@ class KagiScreen extends HookConsumerWidget { return true; } - if (activeWebView?.page.value.pageHistory.canGoBack == true) { + if (page?.pageHistory.canGoBack == true) { lastBackButtonPress.value = null; - await activeWebView?.page.value.controller?.goBack(); + await page?.controller?.goBack(); return true; } @@ -537,10 +569,10 @@ class KagiScreen extends HookConsumerWidget { const Duration(seconds: 2)) { lastBackButtonPress.value = null; - if (activeWebView?.key != null && webViews.length > 1) { - ref - .read(webViewRepositoryProvider.notifier) - .closeTab(activeWebView!.tabId); + if (page != null && webViewWidgets.length > 1) { + await ref + .read(tabRepositoryProvider.notifier) + .deleteTab(page.id); return true; } else { //Mark back as unhandled and navigator will pop @@ -552,7 +584,7 @@ class KagiScreen extends HookConsumerWidget { ..clearSnackBars() ..showSnackBar( SnackBar( - content: (webViews.length > 1) + content: (webViewWidgets.length > 1) ? const Text( 'Please click BACK again to close current tab', ) @@ -578,20 +610,16 @@ class KagiScreen extends HookConsumerWidget { transitionType: SharedAxisTransitionType.horizontal, child: child, ), - key: ValueKey( - (activeWebView != null) - ? webViews.keys - .toList() - .indexOf(activeWebView.tabId) - : null, - ), - index: (activeWebView != null) - ? webViews.keys - .toList() - .indexOf(activeWebView.tabId) + - 1 + // key: ValueKey( + // (activeTabId != null) + // ? activeWebViewStackIndex + // : null, + // ), + index: (activeWebViewStackIndex != null && + activeWebViewStackIndex > -1) + ? activeWebViewStackIndex + 1 : 0, - children: [const LandingContent(), ...webViews.values], + children: [const LandingContent(), ...webViewWidgets], ), ), if (displayedSheet != null) diff --git a/app/lib/features/search_browser/presentation/widgets/sheets/view_tabs_sheet.dart b/app/lib/features/search_browser/presentation/widgets/sheets/view_tabs_sheet.dart index ab1a0694..a32259ab 100644 --- a/app/lib/features/search_browser/presentation/widgets/sheets/view_tabs_sheet.dart +++ b/app/lib/features/search_browser/presentation/widgets/sheets/view_tabs_sheet.dart @@ -1,15 +1,21 @@ import 'dart:async'; +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/features/search_browser/domain/providers.dart'; +import 'package:lensai/features/search_browser/presentation/dialogs/tab_action.dart'; +import 'package:lensai/features/topics/domain/providers.dart'; +import 'package:lensai/features/topics/domain/repositories/tab.dart'; import 'package:lensai/features/topics/presentation/widgets/topic_chips.dart'; -import 'package:lensai/features/web_view/domain/entities/abstract/tab.dart'; import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart'; import 'package:lensai/features/web_view/presentation/widgets/web_view_tab.dart'; class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate { + static const _headerSize = 104.0; + final VoidCallback onClose; _SliverHeaderDelagate({required this.onClose}); @@ -43,10 +49,12 @@ class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate { label: const Text('New Tab'), ), TextButton.icon( - onPressed: () { - ref - .read(webViewRepositoryProvider.notifier) + onPressed: () async { + final topic = ref.read(selectedTopicProvider); + await ref + .read(topicTabRepositoryProvider(topic).notifier) .closeAllTabs(); + onClose(); }, icon: const Icon(Icons.delete), @@ -55,6 +63,7 @@ class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate { ], ), TopicChips(), + const SizedBox(height: 8), ], ), ); @@ -64,10 +73,10 @@ class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate { } @override - double get minExtent => 96; + double get minExtent => _headerSize; @override - double get maxExtent => 96; + double get maxExtent => _headerSize; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => @@ -105,90 +114,135 @@ class ViewTabsSheet extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return CustomScrollView( + return FadingScroll( + fadingSize: 25, + shaderPadding: + const EdgeInsets.only(top: _SliverHeaderDelagate._headerSize), controller: sheetScrollController, - slivers: [ - SliverPersistentHeader( - pinned: true, - delegate: _SliverHeaderDelagate(onClose: onClose), - ), - HookConsumer( - builder: (context, ref, child) { - final tabs = ref.watch( - webViewRepositoryProvider.select((tabs) => tabs.values.toList()), - ); - final activeTab = ref.watch( - webViewTabControllerProvider.select( - (webView) => webView?.tabId, - ), - ); + builder: (context, controller) { + return CustomScrollView( + controller: controller, + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: _SliverHeaderDelagate(onClose: onClose), + ), + HookConsumer( + builder: (context, ref, child) { + final topic = ref.watch(selectedTopicProvider); + final availableTabs = ref.watch( + topicTabRepositoryProvider(topic) + .select((value) => value.valueOrNull ?? []), + ); + final activeTab = ref.watch(webViewTabControllerProvider); - final itemHeight = useMemoized( - () => _calculateItemHeight( - screenWidth: MediaQuery.of(context).size.width, - childAspectRatio: 0.75, - horizontalPadding: 4.0, - mainAxisSpacing: 8.0, - crossAxisSpacing: 8.0, - crossAxisCount: 2, - ), - [MediaQuery.of(context).size.width], - ); + final itemHeight = useMemoized( + () => _calculateItemHeight( + screenWidth: MediaQuery.of(context).size.width, + childAspectRatio: 0.75, + horizontalPadding: 4.0, + mainAxisSpacing: 8.0, + crossAxisSpacing: 8.0, + crossAxisCount: 2, + ), + [MediaQuery.of(context).size.width], + ); - useEffect( - () { - final index = - tabs.indexWhere((webView) => webView.tabId == activeTab); + useEffect( + () { + final index = availableTabs + .indexWhere((webView) => webView == activeTab); - if (index > -1) { - final reversedIndex = tabs.length - 1 - index; - final offset = (reversedIndex ~/ 2) * itemHeight; + if (index > -1) { + final reversedIndex = availableTabs.length - 1 - index; + final offset = (reversedIndex ~/ 2) * itemHeight; - if (offset != sheetScrollController.offset) { - unawaited( - sheetScrollController.animateTo( - offset, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ), - ); - } - } + if (offset != controller.offset) { + unawaited( + controller.animateTo( + offset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ), + ); + } + } - return null; + return null; + }, + [], + ); + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + sliver: SliverGrid.count( + //Sync values for itemHeight calculation _calculateItemHeight + childAspectRatio: 0.75, + mainAxisSpacing: 8.0, + crossAxisSpacing: 8.0, + crossAxisCount: 2, + children: availableTabs.reversed + .map( + (tabId) => Consumer( + key: ValueKey(tabId), + builder: (context, ref, child) { + final tab = ref.watch(tabStateProvider(tabId)); + + return (tab != null) + ? WebViewTab( + tab: tab, + isActive: tabId == activeTab, + onTap: () { + if (tabId != activeTab) { + //Close first to avoid rebuilds + onClose(); + ref + .read( + webViewTabControllerProvider + .notifier, + ) + .showTab(tab.id); + } else { + onClose(); + } + }, + onLongPress: () { + ref + .read( + overlayDialogProvider.notifier, + ) + .show( + TabActionDialog( + initialTab: tab, + onDismiss: ref + .read( + overlayDialogProvider + .notifier, + ) + .dismiss, + ), + ); + }, + onDelete: () async { + await ref + .read( + tabRepositoryProvider.notifier, + ) + .deleteTab(tab.id); + }, + ) + : const SizedBox.shrink(); + }, + ), + ) + .toList(), + ), + ); }, - [], - ); - - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - sliver: SliverGrid.count( - //Sync values for itemHeight calculation _calculateItemHeight - childAspectRatio: 0.75, - mainAxisSpacing: 8.0, - crossAxisSpacing: 8.0, - crossAxisCount: 2, - children: tabs.reversed - .map( - (webView) => HookBuilder( - key: ValueKey(webView.tabId), - builder: (context) { - final tab = useValueListenable(webView.page) as ITab; - - return WebViewTab( - tab: tab, - isActive: webView.tabId == activeTab, - onClose: onClose, - ); - }, - ), - ) - .toList(), - ), - ); - }, - ), - ], + ), + ], + ); + }, ); } } diff --git a/app/lib/features/search_browser/presentation/widgets/tabs_action_button.dart b/app/lib/features/search_browser/presentation/widgets/tabs_action_button.dart index 53c0516a..71e8629a 100644 --- a/app/lib/features/search_browser/presentation/widgets/tabs_action_button.dart +++ b/app/lib/features/search_browser/presentation/widgets/tabs_action_button.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; +import 'package:lensai/features/web_view/domain/providers.dart'; class TabsActionButton extends HookConsumerWidget { final bool isActive; @@ -17,7 +17,7 @@ class TabsActionButton extends HookConsumerWidget { final theme = Theme.of(context); final tabCount = - ref.watch(webViewRepositoryProvider.select((tabs) => tabs.length)); + ref.watch(activeTabsProvider.select((tabs) => tabs.length)); return InkWell( onTap: onTap, diff --git a/app/lib/features/topics/data/database/daos/tab.dart b/app/lib/features/topics/data/database/daos/tab.dart index 64ca0a6a..148292fa 100644 --- a/app/lib/features/topics/data/database/daos/tab.dart +++ b/app/lib/features/topics/data/database/daos/tab.dart @@ -8,11 +8,42 @@ part 'tab.g.dart'; class TabDao extends DatabaseAccessor with _$TabDaoMixin { TabDao(super.db); - Future upsertTab(ITab tab) { + Stream> watchTabs() { + return (db.tab.select()..orderBy([(u) => OrderingTerm.asc(u.id)])).watch(); + } + + Stream watchTab(String tabId) { + return (db.tab.select()..where((t) => t.id.equals(tabId))) + .watchSingleOrNull(); + } + + Stream watchTabExisiting(String tabId) { + final existsStatement = + existsQuery(db.tab.select()..where((t) => t.id.equals(tabId))); + + return selectExpressions([existsStatement]) + .map((row) => row.read(existsStatement)!) + .watchSingle(); + } + + Stream> watchTopicTabs(String? topicId) { + final query = (selectOnly(db.tab) + ..addColumns([db.tab.id]) + ..where( + (topicId == null) + ? db.tab.topicId.isNull() + : db.tab.topicId.equals(topicId), + ) + ..orderBy([OrderingTerm.asc(db.tab.id)])); + + return query.map((row) => row.read(db.tab.id)!).watch(); + } + + Future upsertTab(ITab tab, DateTime timestamp) { return db.tab.insertOne( TabCompanion.insert( id: tab.id, - timestamp: DateTime.now(), + timestamp: timestamp, url: tab.url, topicId: Value(tab.topicId), title: Value(tab.title), @@ -24,6 +55,7 @@ class TabDao extends DatabaseAccessor with _$TabDaoMixin { Future updateTab( String id, { + required DateTime timestamp, Value url = const Value.absent(), Value title = const Value.absent(), Value topicId = const Value.absent(), @@ -33,7 +65,7 @@ class TabDao extends DatabaseAccessor with _$TabDaoMixin { return statement.write( TabCompanion( - timestamp: Value(DateTime.now()), + timestamp: Value(timestamp), url: url, title: title, topicId: topicId, @@ -41,4 +73,18 @@ class TabDao extends DatabaseAccessor with _$TabDaoMixin { ), ); } + + Future deleteTab(String id) { + return db.tab.deleteOne(TabCompanion.custom(id: Variable(id))); + } + + Future deleteTopicTabs(String? topicId) { + return (db.tab.delete() + ..where( + (t) => (topicId == null) + ? t.topicId.isNull() + : t.topicId.equals(topicId), + )) + .go(); + } } diff --git a/app/lib/features/topics/data/database/daos/topic.dart b/app/lib/features/topics/data/database/daos/topic.dart index d0e66d55..d8e38a45 100644 --- a/app/lib/features/topics/data/database/daos/topic.dart +++ b/app/lib/features/topics/data/database/daos/topic.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:drift/drift.dart'; import 'package:lensai/core/uuid.dart'; import 'package:lensai/features/topics/data/database/database.dart'; +import 'package:lensai/features/topics/data/models/topic_data.dart'; part 'topic.g.dart'; diff --git a/app/lib/features/topics/data/database/database.dart b/app/lib/features/topics/data/database/database.dart index 45a7281f..258969dd 100644 --- a/app/lib/features/topics/data/database/database.dart +++ b/app/lib/features/topics/data/database/database.dart @@ -5,6 +5,7 @@ import 'package:lensai/features/topics/data/database/daos/tab.dart'; import 'package:lensai/features/topics/data/database/daos/topic.dart'; import 'package:lensai/features/topics/data/database/drift/converters/color.dart'; import 'package:lensai/features/topics/data/database/drift/converters/uri.dart'; +import 'package:lensai/features/topics/data/models/topic_data.dart'; part 'database.g.dart'; diff --git a/app/lib/features/topics/data/database/database.drift b/app/lib/features/topics/data/database/database.drift index 518bc3aa..ada2d0c5 100644 --- a/app/lib/features/topics/data/database/database.drift +++ b/app/lib/features/topics/data/database/database.drift @@ -1,11 +1,12 @@ import 'package:lensai/features/topics/data/database/drift/converters/color.dart'; import 'package:lensai/features/topics/data/database/drift/converters/uri.dart'; +import 'package:lensai/features/topics/data/models/topic_data.dart'; CREATE TABLE topic ( id TEXT PRIMARY KEY NOT NULL, name TEXT, color INTEGER NOT NULL MAPPED BY `const ColorConverter()` -); +) WITH TopicData; CREATE TABLE tab ( id TEXT PRIMARY KEY NOT NULL, @@ -16,12 +17,18 @@ CREATE TABLE tab ( screenshot BLOB ); -topics: - SELECT topic.* +topicsWithCount WITH TopicDataWithCount: + SELECT + topic.*, + tab_agg.tab_count FROM topic LEFT JOIN ( - SELECT topic_id, MAX(timestamp) AS last_updated + SELECT + topic_id, + COUNT(*) AS tab_count, + MAX(timestamp) AS last_updated FROM tab GROUP BY topic_id - ) AS tab_max ON topic.id = tab_max.topic_id - ORDER BY tab_max.last_updated DESC NULLS FIRST; + ) AS tab_agg ON topic.id = tab_agg.topic_id + ORDER BY tab_agg.last_updated DESC NULLS FIRST; + diff --git a/app/lib/features/topics/data/database/database.g.dart b/app/lib/features/topics/data/database/database.g.dart index a922881f..cc0200e9 100644 --- a/app/lib/features/topics/data/database/database.g.dart +++ b/app/lib/features/topics/data/database/database.g.dart @@ -56,81 +56,6 @@ class Topic extends Table with TableInfo { bool get dontWriteConstraints => true; } -class TopicData extends DataClass implements Insertable { - final String id; - final String? name; - final Color color; - const TopicData({required this.id, this.name, required this.color}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - if (!nullToAbsent || name != null) { - map['name'] = Variable(name); - } - { - map['color'] = Variable(Topic.$convertercolor.toSql(color)); - } - return map; - } - - factory TopicData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return TopicData( - id: serializer.fromJson(json['id']), - name: serializer.fromJson(json['name']), - color: serializer.fromJson(json['color']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'name': serializer.toJson(name), - 'color': serializer.toJson(color), - }; - } - - TopicData copyWith( - {String? id, - Value name = const Value.absent(), - Color? color}) => - TopicData( - id: id ?? this.id, - name: name.present ? name.value : this.name, - color: color ?? this.color, - ); - TopicData copyWithCompanion(TopicCompanion data) { - return TopicData( - id: data.id.present ? data.id.value : this.id, - name: data.name.present ? data.name.value : this.name, - color: data.color.present ? data.color.value : this.color, - ); - } - - @override - String toString() { - return (StringBuffer('TopicData(') - ..write('id: $id, ') - ..write('name: $name, ') - ..write('color: $color') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash(id, name, color); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is TopicData && - other.id == this.id && - other.name == this.name && - other.color == this.color); -} - class TopicCompanion extends UpdateCompanion { final Value id; final Value name; @@ -511,14 +436,19 @@ abstract class _$TabDatabase extends GeneratedDatabase { late final Tab tab = Tab(this); late final TopicDao topicDao = TopicDao(this as TabDatabase); late final TabDao tabDao = TabDao(this as TabDatabase); - Selectable topics() { + Selectable topicsWithCount() { return customSelect( - 'SELECT topic.* FROM topic LEFT JOIN (SELECT topic_id, MAX(timestamp) AS last_updated FROM tab GROUP BY topic_id) AS tab_max ON topic.id = tab_max.topic_id ORDER BY tab_max.last_updated DESC NULLS FIRST', + 'SELECT topic.*, tab_agg.tab_count FROM topic LEFT JOIN (SELECT topic_id, COUNT(*) AS tab_count, MAX(timestamp) AS last_updated FROM tab GROUP BY topic_id) AS tab_agg ON topic.id = tab_agg.topic_id ORDER BY tab_agg.last_updated DESC NULLS FIRST', variables: [], readsFrom: { topic, tab, - }).asyncMap(topic.mapFromRow); + }).map((QueryRow row) => TopicDataWithCount( + id: row.read('id'), + name: row.readNullable('name'), + color: Topic.$convertercolor.fromSql(row.read('color')), + tabCount: row.readNullable('tab_count'), + )); } @override diff --git a/app/lib/features/topics/data/database/drift/converters/uri.dart b/app/lib/features/topics/data/database/drift/converters/uri.dart index 1f028909..cb39388b 100644 --- a/app/lib/features/topics/data/database/drift/converters/uri.dart +++ b/app/lib/features/topics/data/database/drift/converters/uri.dart @@ -2,6 +2,8 @@ import 'package:drift/drift.dart'; import 'package:lensai/utils/uri_parser.dart' as uri_parser; class UriConverter extends TypeConverter { + const UriConverter(); + @override Uri fromSql(String fromDb) { return uri_parser.tryParseUrl(fromDb, eagerParsing: true)!; diff --git a/app/lib/features/topics/data/models/topic_data.dart b/app/lib/features/topics/data/models/topic_data.dart new file mode 100644 index 00000000..cd1a2d14 --- /dev/null +++ b/app/lib/features/topics/data/models/topic_data.dart @@ -0,0 +1,41 @@ +import 'dart:ui'; + +import 'package:fast_equatable/fast_equatable.dart'; + +class TopicData with FastEquatable { + final String id; + final String? name; + final Color color; + + TopicData({required this.id, this.name, required this.color}); + + @override + bool get cacheHash => true; + + @override + List get hashParameters => [ + id, + name, + color, + ]; +} + +class TopicDataWithCount extends TopicData { + final int? tabCount; + + TopicDataWithCount({ + required super.id, + super.name, + required super.color, + required this.tabCount, + }); + + @override + bool get cacheHash => true; + + @override + List get hashParameters => [ + ...super.hashParameters, + tabCount, + ]; +} diff --git a/app/lib/features/topics/data/providers.dart b/app/lib/features/topics/data/providers.dart index b9f6cc4c..ab56083a 100644 --- a/app/lib/features/topics/data/providers.dart +++ b/app/lib/features/topics/data/providers.dart @@ -35,3 +35,15 @@ TabDatabase tabDatabase(TabDatabaseRef ref) { }), ); } + +@Riverpod() +Stream isTabExisting(IsTabExistingRef ref, String tabId) { + final db = ref.watch(tabDatabaseProvider); + return db.tabDao.watchTabExisiting(tabId); +} + +@Riverpod() +Stream tabData(TabDataRef ref, String tabId) { + final db = ref.watch(tabDatabaseProvider); + return db.tabDao.watchTab(tabId); +} diff --git a/app/lib/features/topics/data/providers.g.dart b/app/lib/features/topics/data/providers.g.dart index 13b43baa..4682778d 100644 --- a/app/lib/features/topics/data/providers.g.dart +++ b/app/lib/features/topics/data/providers.g.dart @@ -20,5 +20,280 @@ final tabDatabaseProvider = Provider.internal( ); typedef TabDatabaseRef = ProviderRef; +String _$isTabExistingHash() => r'98fc7bf073ba8f73c20df6ba7076672ff497246f'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [isTabExisting]. +@ProviderFor(isTabExisting) +const isTabExistingProvider = IsTabExistingFamily(); + +/// See also [isTabExisting]. +class IsTabExistingFamily extends Family> { + /// See also [isTabExisting]. + const IsTabExistingFamily(); + + /// See also [isTabExisting]. + IsTabExistingProvider call( + String tabId, + ) { + return IsTabExistingProvider( + tabId, + ); + } + + @override + IsTabExistingProvider getProviderOverride( + covariant IsTabExistingProvider provider, + ) { + return call( + provider.tabId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'isTabExistingProvider'; +} + +/// See also [isTabExisting]. +class IsTabExistingProvider extends AutoDisposeStreamProvider { + /// See also [isTabExisting]. + IsTabExistingProvider( + String tabId, + ) : this._internal( + (ref) => isTabExisting( + ref as IsTabExistingRef, + tabId, + ), + from: isTabExistingProvider, + name: r'isTabExistingProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$isTabExistingHash, + dependencies: IsTabExistingFamily._dependencies, + allTransitiveDependencies: + IsTabExistingFamily._allTransitiveDependencies, + tabId: tabId, + ); + + IsTabExistingProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.tabId, + }) : super.internal(); + + final String tabId; + + @override + Override overrideWith( + Stream Function(IsTabExistingRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: IsTabExistingProvider._internal( + (ref) => create(ref as IsTabExistingRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + tabId: tabId, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _IsTabExistingProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is IsTabExistingProvider && other.tabId == tabId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, tabId.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin IsTabExistingRef on AutoDisposeStreamProviderRef { + /// The parameter `tabId` of this provider. + String get tabId; +} + +class _IsTabExistingProviderElement + extends AutoDisposeStreamProviderElement with IsTabExistingRef { + _IsTabExistingProviderElement(super.provider); + + @override + String get tabId => (origin as IsTabExistingProvider).tabId; +} + +String _$tabDataHash() => r'8b799386305d0c31103bc48a5a467df41d57d065'; + +/// See also [tabData]. +@ProviderFor(tabData) +const tabDataProvider = TabDataFamily(); + +/// See also [tabData]. +class TabDataFamily extends Family> { + /// See also [tabData]. + const TabDataFamily(); + + /// See also [tabData]. + TabDataProvider call( + String tabId, + ) { + return TabDataProvider( + tabId, + ); + } + + @override + TabDataProvider getProviderOverride( + covariant TabDataProvider provider, + ) { + return call( + provider.tabId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'tabDataProvider'; +} + +/// See also [tabData]. +class TabDataProvider extends AutoDisposeStreamProvider { + /// See also [tabData]. + TabDataProvider( + String tabId, + ) : this._internal( + (ref) => tabData( + ref as TabDataRef, + tabId, + ), + from: tabDataProvider, + name: r'tabDataProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabDataHash, + dependencies: TabDataFamily._dependencies, + allTransitiveDependencies: TabDataFamily._allTransitiveDependencies, + tabId: tabId, + ); + + TabDataProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.tabId, + }) : super.internal(); + + final String tabId; + + @override + Override overrideWith( + Stream Function(TabDataRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: TabDataProvider._internal( + (ref) => create(ref as TabDataRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + tabId: tabId, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _TabDataProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is TabDataProvider && other.tabId == tabId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, tabId.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin TabDataRef on AutoDisposeStreamProviderRef { + /// The parameter `tabId` of this provider. + String get tabId; +} + +class _TabDataProviderElement extends AutoDisposeStreamProviderElement + with TabDataRef { + _TabDataProviderElement(super.provider); + + @override + String get tabId => (origin as TabDataProvider).tabId; +} // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/topics/domain/providers.dart b/app/lib/features/topics/domain/providers.dart index a4022831..d58dea5a 100644 --- a/app/lib/features/topics/domain/providers.dart +++ b/app/lib/features/topics/domain/providers.dart @@ -1,19 +1,14 @@ import 'dart:ui'; import 'package:collection/collection.dart'; -import 'package:lensai/features/topics/data/database/database.dart'; +import 'package:lensai/features/topics/data/models/topic_data.dart'; +import 'package:lensai/features/topics/data/providers.dart'; import 'package:lensai/features/topics/domain/repositories/topic.dart'; import 'package:lensai/features/topics/utils/color_palette.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'providers.g.dart'; -@Riverpod() -Stream> topicList(TopicListRef ref) { - final repository = ref.watch(topicRepositoryProvider.notifier); - return repository.watchTopics(); -} - @Riverpod(keepAlive: true) class SelectedTopic extends _$SelectedTopic { void setTopic(String id) { @@ -40,21 +35,22 @@ class SelectedTopic extends _$SelectedTopic { @Riverpod() Stream selectedTopicData(SelectedTopicDataRef ref) { - final repository = ref.watch(topicRepositoryProvider.notifier); - final selectedBangTrigger = ref.watch(selectedTopicProvider); - return repository.watchTopic(selectedBangTrigger); -} + final db = ref.watch(tabDatabaseProvider); + final selectedTopic = ref.watch(selectedTopicProvider); -@Riverpod() -Future> distinctTopicColors(DistinctTopicColorsRef ref) { - final repository = ref.watch(topicRepositoryProvider.notifier); - return repository.getDistinctColors(); + if (selectedTopic != null) { + return db.topicDao.getTopicData(selectedTopic).watchSingleOrNull(); + } + + return Stream.value(null); } @Riverpod() Future unusedRandomTopicColor(UnusedRandomTopicColorRef ref) async { + final repository = ref.watch(topicRepositoryProvider.notifier); + final allColors = colorTypes.flattened.toList(); - final usedColors = await ref.read(distinctTopicColorsProvider.future); + final usedColors = await repository.getDistinctColors(); Color randomColor; do { diff --git a/app/lib/features/topics/domain/providers.g.dart b/app/lib/features/topics/domain/providers.g.dart index b35e19df..89808504 100644 --- a/app/lib/features/topics/domain/providers.g.dart +++ b/app/lib/features/topics/domain/providers.g.dart @@ -6,21 +6,7 @@ part of 'providers.dart'; // RiverpodGenerator // ************************************************************************** -String _$topicListHash() => r'7220aef5653bb3c2b44b016ce4f083f20896b353'; - -/// See also [topicList]. -@ProviderFor(topicList) -final topicListProvider = AutoDisposeStreamProvider>.internal( - topicList, - name: r'topicListProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$topicListHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef TopicListRef = AutoDisposeStreamProviderRef>; -String _$selectedTopicDataHash() => r'191ad088ab90ef2348a836593f92b48d095afdc8'; +String _$selectedTopicDataHash() => r'fa450784052498014f592c356bde4ff5ec675281'; /// See also [selectedTopicData]. @ProviderFor(selectedTopicData) @@ -36,25 +22,8 @@ final selectedTopicDataProvider = ); typedef SelectedTopicDataRef = AutoDisposeStreamProviderRef; -String _$distinctTopicColorsHash() => - r'c671f349615313d3b17e93e9e46fae12889e5e1f'; - -/// See also [distinctTopicColors]. -@ProviderFor(distinctTopicColors) -final distinctTopicColorsProvider = - AutoDisposeFutureProvider>.internal( - distinctTopicColors, - name: r'distinctTopicColorsProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$distinctTopicColorsHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef DistinctTopicColorsRef = AutoDisposeFutureProviderRef>; String _$unusedRandomTopicColorHash() => - r'8f6907bb50a5bf2ac0320e93531d9e61616337b9'; + r'099730b0d987cc37bcae4ef5c999cbebfa67d7da'; /// See also [unusedRandomTopicColor]. @ProviderFor(unusedRandomTopicColor) diff --git a/app/lib/features/topics/domain/repositories/tab.dart b/app/lib/features/topics/domain/repositories/tab.dart index 141a18bb..4e6b5b94 100644 --- a/app/lib/features/topics/domain/repositories/tab.dart +++ b/app/lib/features/topics/domain/repositories/tab.dart @@ -1,5 +1,7 @@ +import 'package:drift/drift.dart'; import 'package:lensai/features/topics/data/database/database.dart'; import 'package:lensai/features/topics/data/providers.dart'; +import 'package:lensai/features/web_view/domain/entities/abstract/tab.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'tab.g.dart'; @@ -9,7 +11,49 @@ class TabRepository extends _$TabRepository { late TabDatabase _db; @override - void build() { + Stream> build() { _db = ref.watch(tabDatabaseProvider); + return _db.tabDao.watchTabs(); + } + + Future updateTab( + String id, { + Value url = const Value.absent(), + Value title = const Value.absent(), + Value topicId = const Value.absent(), + Value screenshot = const Value.absent(), + }) { + return _db.tabDao.updateTab( + id, + timestamp: DateTime.now(), + url: url, + title: title, + topicId: topicId, + screenshot: screenshot, + ); + } + + Future deleteTab(String id) { + return _db.tabDao.deleteTab(id); + } +} + +@Riverpod() +class TopicTabRepository extends _$TopicTabRepository { + late TabDatabase _db; + + @override + Stream> build(String? topicId) { + _db = ref.watch(tabDatabaseProvider); + return _db.tabDao.watchTopicTabs(topicId); + } + + Future addTab(ITab tab) { + assert(tab.topicId == topicId); + return _db.tabDao.upsertTab(tab, DateTime.now()); + } + + Future closeAllTabs() { + return _db.tabDao.deleteTopicTabs(topicId); } } diff --git a/app/lib/features/topics/domain/repositories/tab.g.dart b/app/lib/features/topics/domain/repositories/tab.g.dart index e45e343f..36169d8c 100644 --- a/app/lib/features/topics/domain/repositories/tab.g.dart +++ b/app/lib/features/topics/domain/repositories/tab.g.dart @@ -6,11 +6,12 @@ part of 'tab.dart'; // RiverpodGenerator // ************************************************************************** -String _$tabRepositoryHash() => r'3f564f553e9586df34e927b20fe4bc860e0814e9'; +String _$tabRepositoryHash() => r'454e182a99910512be72279f68caa0af125ed165'; /// See also [TabRepository]. @ProviderFor(TabRepository) -final tabRepositoryProvider = NotifierProvider.internal( +final tabRepositoryProvider = + StreamNotifierProvider>.internal( TabRepository.new, name: r'tabRepositoryProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -20,6 +21,172 @@ final tabRepositoryProvider = NotifierProvider.internal( allTransitiveDependencies: null, ); -typedef _$TabRepository = Notifier; +typedef _$TabRepository = StreamNotifier>; +String _$topicTabRepositoryHash() => + r'52ceb013e0b31046fdd0a00d031392ba84b9ecda'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$TopicTabRepository + extends BuildlessAutoDisposeStreamNotifier> { + late final String? topicId; + + Stream> build( + String? topicId, + ); +} + +/// See also [TopicTabRepository]. +@ProviderFor(TopicTabRepository) +const topicTabRepositoryProvider = TopicTabRepositoryFamily(); + +/// See also [TopicTabRepository]. +class TopicTabRepositoryFamily extends Family>> { + /// See also [TopicTabRepository]. + const TopicTabRepositoryFamily(); + + /// See also [TopicTabRepository]. + TopicTabRepositoryProvider call( + String? topicId, + ) { + return TopicTabRepositoryProvider( + topicId, + ); + } + + @override + TopicTabRepositoryProvider getProviderOverride( + covariant TopicTabRepositoryProvider provider, + ) { + return call( + provider.topicId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'topicTabRepositoryProvider'; +} + +/// See also [TopicTabRepository]. +class TopicTabRepositoryProvider extends AutoDisposeStreamNotifierProviderImpl< + TopicTabRepository, List> { + /// See also [TopicTabRepository]. + TopicTabRepositoryProvider( + String? topicId, + ) : this._internal( + () => TopicTabRepository()..topicId = topicId, + from: topicTabRepositoryProvider, + name: r'topicTabRepositoryProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$topicTabRepositoryHash, + dependencies: TopicTabRepositoryFamily._dependencies, + allTransitiveDependencies: + TopicTabRepositoryFamily._allTransitiveDependencies, + topicId: topicId, + ); + + TopicTabRepositoryProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.topicId, + }) : super.internal(); + + final String? topicId; + + @override + Stream> runNotifierBuild( + covariant TopicTabRepository notifier, + ) { + return notifier.build( + topicId, + ); + } + + @override + Override overrideWith(TopicTabRepository Function() create) { + return ProviderOverride( + origin: this, + override: TopicTabRepositoryProvider._internal( + () => create()..topicId = topicId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + topicId: topicId, + ), + ); + } + + @override + AutoDisposeStreamNotifierProviderElement> + createElement() { + return _TopicTabRepositoryProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is TopicTabRepositoryProvider && other.topicId == topicId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, topicId.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin TopicTabRepositoryRef + on AutoDisposeStreamNotifierProviderRef> { + /// The parameter `topicId` of this provider. + String? get topicId; +} + +class _TopicTabRepositoryProviderElement + extends AutoDisposeStreamNotifierProviderElement> with TopicTabRepositoryRef { + _TopicTabRepositoryProviderElement(super.provider); + + @override + String? get topicId => (origin as TopicTabRepositoryProvider).topicId; +} // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/topics/domain/repositories/topic.dart b/app/lib/features/topics/domain/repositories/topic.dart index 51f8ef46..5321cb12 100644 --- a/app/lib/features/topics/domain/repositories/topic.dart +++ b/app/lib/features/topics/domain/repositories/topic.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'package:lensai/features/topics/data/database/database.dart'; +import 'package:lensai/features/topics/data/models/topic_data.dart'; import 'package:lensai/features/topics/data/providers.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -11,8 +12,9 @@ class TopicRepository extends _$TopicRepository { late TabDatabase _db; @override - void build() { + Stream> build() { _db = ref.watch(tabDatabaseProvider); + return _db.topicsWithCount().watch(); } Future addTopic({required String? name, required Color color}) { @@ -31,18 +33,6 @@ class TopicRepository extends _$TopicRepository { return _db.topicDao.deleteTopic(id); } - Stream> watchTopics() { - return _db.topics().watch(); - } - - Stream watchTopic(String? id) { - if (id != null) { - return _db.topicDao.getTopicData(id).watchSingleOrNull(); - } else { - return Stream.value(null); - } - } - Future> getDistinctColors() { return _db.topicDao .getDistinctColors() diff --git a/app/lib/features/topics/domain/repositories/topic.g.dart b/app/lib/features/topics/domain/repositories/topic.g.dart index f7d9fe46..d1cff0fa 100644 --- a/app/lib/features/topics/domain/repositories/topic.g.dart +++ b/app/lib/features/topics/domain/repositories/topic.g.dart @@ -6,12 +6,12 @@ part of 'topic.dart'; // RiverpodGenerator // ************************************************************************** -String _$topicRepositoryHash() => r'f0e8d3170d3706a79decfbe4700f20fcdebfae2c'; +String _$topicRepositoryHash() => r'3b75c5fb1a081ba6d90e2ccb781469d0aa87c415'; /// See also [TopicRepository]. @ProviderFor(TopicRepository) final topicRepositoryProvider = - NotifierProvider.internal( + StreamNotifierProvider>.internal( TopicRepository.new, name: r'topicRepositoryProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -21,6 +21,6 @@ final topicRepositoryProvider = allTransitiveDependencies: null, ); -typedef _$TopicRepository = Notifier; +typedef _$TopicRepository = StreamNotifier>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/topics/presentation/screens/topic_list.dart b/app/lib/features/topics/presentation/screens/topic_list.dart index c0cb4097..3d6ab4d3 100644 --- a/app/lib/features/topics/presentation/screens/topic_list.dart +++ b/app/lib/features/topics/presentation/screens/topic_list.dart @@ -1,7 +1,8 @@ +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/features/topics/data/database/database.dart'; +import 'package:lensai/features/topics/data/models/topic_data.dart'; import 'package:lensai/features/topics/domain/providers.dart'; import 'package:lensai/features/topics/domain/repositories/topic.dart'; import 'package:lensai/features/topics/presentation/widgets/topic_dialog.dart'; @@ -147,38 +148,44 @@ class TopicListScreen extends HookConsumerWidget { ), body: HookConsumer( builder: (context, ref, child) { - final topicsAsync = ref.watch(topicListProvider); + final topicsAsync = ref.watch(topicRepositoryProvider); final selectedTopic = ref.watch(selectedTopicProvider); return Skeletonizer( enabled: topicsAsync.isLoading, child: topicsAsync.when( - data: (topics) => ListView.builder( - itemCount: topics.length, - itemBuilder: (context, index) { - final topic = topics[index]; - return _TopicTile( - topic, - key: ValueKey(topic.id), - isSelected: topic.id == selectedTopic, - onEdit: (edited) async { - await ref - .read(topicRepositoryProvider.notifier) - .replaceTopic( - id: topic.id, - name: edited.name, - color: edited.color, - ); - }, - onDelete: () async { - await ref - .read(topicRepositoryProvider.notifier) - .deleteTopic(topic.id); - }, - onTap: () { - ref - .read(selectedTopicProvider.notifier) - .toggleTopic(topic.id); + data: (topics) => FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView.builder( + controller: controller, + itemCount: topics.length, + itemBuilder: (context, index) { + final topic = topics[index]; + return _TopicTile( + topic, + key: ValueKey(topic.id), + isSelected: topic.id == selectedTopic, + onEdit: (edited) async { + await ref + .read(topicRepositoryProvider.notifier) + .replaceTopic( + id: topic.id, + name: edited.name, + color: edited.color, + ); + }, + onDelete: () async { + await ref + .read(topicRepositoryProvider.notifier) + .deleteTopic(topic.id); + }, + onTap: () { + ref + .read(selectedTopicProvider.notifier) + .toggleTopic(topic.id); + }, + ); }, ); }, @@ -187,7 +194,7 @@ class TopicListScreen extends HookConsumerWidget { loading: () => ListView.builder( itemCount: 3, itemBuilder: (context, index) => _TopicTile( - const TopicData(id: 'null', color: Colors.transparent), + TopicData(id: 'null', color: Colors.transparent), isSelected: false, onEdit: (_) {}, onDelete: () {}, diff --git a/app/lib/features/topics/presentation/widgets/topic_chips.dart b/app/lib/features/topics/presentation/widgets/topic_chips.dart index 0415627a..14939f92 100644 --- a/app/lib/features/topics/presentation/widgets/topic_chips.dart +++ b/app/lib/features/topics/presentation/widgets/topic_chips.dart @@ -3,12 +3,13 @@ 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/topics/domain/providers.dart'; +import 'package:lensai/features/topics/domain/repositories/topic.dart'; import 'package:lensai/presentation/widgets/selectable_chips.dart'; class TopicChips extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final topicsAsync = ref.watch(topicListProvider); + final topicsAsync = ref.watch(topicRepositoryProvider); final selectedTopic = ref .watch(selectedTopicDataProvider.select((value) => value.valueOrNull)); @@ -17,6 +18,7 @@ class TopicChips extends HookConsumerWidget { height: 48, child: Row( children: [ + const SizedBox(width: 16), if (selectedTopic != null || availableTopics.isNotEmpty) Expanded( child: SelectableChips( @@ -31,6 +33,7 @@ class TopicChips extends HookConsumerWidget { ), ), itemLabel: (topic) => Text(topic.name ?? 'New Topic'), + itemBadgeCount: (topic) => topic.tabCount, availableItems: availableTopics, selectedItem: selectedTopic, onSelected: (topic) { diff --git a/app/lib/features/web_view/domain/entities/web_view_page.dart b/app/lib/features/web_view/domain/entities/web_view_page.dart index 854f6b62..8b5ab8ec 100644 --- a/app/lib/features/web_view/domain/entities/web_view_page.dart +++ b/app/lib/features/web_view/domain/entities/web_view_page.dart @@ -1,19 +1,19 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:fast_equatable/fast_equatable.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:lensai/core/uuid.dart'; import 'package:lensai/domain/entities/web_page_info.dart'; import 'package:lensai/features/web_view/domain/entities/abstract/tab.dart'; +part 'web_view_page.g.dart'; + typedef PageHistory = ({bool canGoBack, bool canGoForward}); +@CopyWith() class WebViewPage extends WebPageInfo with FastEquatable implements ITab { - //@CopyWithField(immutable: true) - final Key key; - @override - //@CopyWithField(immutable: true) + @CopyWithField(immutable: true) final String id; final InAppWebViewController? controller; @@ -30,7 +30,6 @@ class WebViewPage extends WebPageInfo with FastEquatable implements ITab { final PageHistory pageHistory; WebViewPage({ - required this.key, required this.id, required this.controller, required super.url, @@ -52,15 +51,13 @@ class WebViewPage extends WebPageInfo with FastEquatable implements ITab { super.favicon, this.screenshot, this.pageHistory = (canGoBack: false, canGoForward: false), - }) : key = GlobalKey(), - id = id ?? uuid.v7(); + }) : id = id ?? uuid.v7(); @override bool get cacheHash => true; @override List get hashParameters => [ - key, id, controller, super.url, diff --git a/app/lib/features/web_view/domain/entities/web_view_page.g.dart b/app/lib/features/web_view/domain/entities/web_view_page.g.dart new file mode 100644 index 00000000..8d2799c4 --- /dev/null +++ b/app/lib/features/web_view/domain/entities/web_view_page.g.dart @@ -0,0 +1,137 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'web_view_page.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$WebViewPageCWProxy { + WebViewPage controller(InAppWebViewController? controller); + + WebViewPage url(Uri url); + + WebViewPage sslError(SslError? sslError); + + WebViewPage title(String? title); + + WebViewPage topicId(String? topicId); + + WebViewPage favicon(Favicon? favicon); + + WebViewPage screenshot(Uint8List? screenshot); + + WebViewPage pageHistory(({bool canGoBack, bool canGoForward}) pageHistory); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebViewPage(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// WebViewPage(...).copyWith(id: 12, name: "My name") + /// ```` + WebViewPage call({ + InAppWebViewController? controller, + Uri? url, + SslError? sslError, + String? title, + String? topicId, + Favicon? favicon, + Uint8List? screenshot, + ({bool canGoBack, bool canGoForward})? pageHistory, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfWebViewPage.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfWebViewPage.copyWith.fieldName(...)` +class _$WebViewPageCWProxyImpl implements _$WebViewPageCWProxy { + const _$WebViewPageCWProxyImpl(this._value); + + final WebViewPage _value; + + @override + WebViewPage controller(InAppWebViewController? controller) => + this(controller: controller); + + @override + WebViewPage url(Uri url) => this(url: url); + + @override + WebViewPage sslError(SslError? sslError) => this(sslError: sslError); + + @override + WebViewPage title(String? title) => this(title: title); + + @override + WebViewPage topicId(String? topicId) => this(topicId: topicId); + + @override + WebViewPage favicon(Favicon? favicon) => this(favicon: favicon); + + @override + WebViewPage screenshot(Uint8List? screenshot) => this(screenshot: screenshot); + + @override + WebViewPage pageHistory(({bool canGoBack, bool canGoForward}) pageHistory) => + this(pageHistory: pageHistory); + + @override + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebViewPage(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// WebViewPage(...).copyWith(id: 12, name: "My name") + /// ```` + WebViewPage call({ + Object? controller = const $CopyWithPlaceholder(), + Object? url = const $CopyWithPlaceholder(), + Object? sslError = const $CopyWithPlaceholder(), + Object? title = const $CopyWithPlaceholder(), + Object? topicId = const $CopyWithPlaceholder(), + Object? favicon = const $CopyWithPlaceholder(), + Object? screenshot = const $CopyWithPlaceholder(), + Object? pageHistory = const $CopyWithPlaceholder(), + }) { + return WebViewPage( + id: _value.id, + controller: controller == const $CopyWithPlaceholder() + ? _value.controller + // ignore: cast_nullable_to_non_nullable + : controller as InAppWebViewController?, + url: url == const $CopyWithPlaceholder() || url == null + ? _value.url + // ignore: cast_nullable_to_non_nullable + : url as Uri, + sslError: sslError == const $CopyWithPlaceholder() + ? _value.sslError + // ignore: cast_nullable_to_non_nullable + : sslError as SslError?, + title: title == const $CopyWithPlaceholder() + ? _value.title + // ignore: cast_nullable_to_non_nullable + : title as String?, + topicId: topicId == const $CopyWithPlaceholder() + ? _value.topicId + // ignore: cast_nullable_to_non_nullable + : topicId as String?, + favicon: favicon == const $CopyWithPlaceholder() + ? _value.favicon + // ignore: cast_nullable_to_non_nullable + : favicon as Favicon?, + screenshot: screenshot == const $CopyWithPlaceholder() + ? _value.screenshot + // ignore: cast_nullable_to_non_nullable + : screenshot as Uint8List?, + pageHistory: + pageHistory == const $CopyWithPlaceholder() || pageHistory == null + ? _value.pageHistory + // ignore: cast_nullable_to_non_nullable + : pageHistory as ({bool canGoBack, bool canGoForward}), + ); + } +} + +extension $WebViewPageCopyWith on WebViewPage { + /// Returns a callable class that can be used as follows: `instanceOfWebViewPage.copyWith(...)` or like so:`instanceOfWebViewPage.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$WebViewPageCWProxy get copyWith => _$WebViewPageCWProxyImpl(this); +} diff --git a/app/lib/features/web_view/domain/providers.dart b/app/lib/features/web_view/domain/providers.dart index 798d3b8d..d02d6420 100644 --- a/app/lib/features/web_view/domain/providers.dart +++ b/app/lib/features/web_view/domain/providers.dart @@ -5,6 +5,8 @@ import 'package:flutter/services.dart' show rootBundle; import 'package:lensai/features/content_block/domain/repositories/host.dart'; import 'package:lensai/features/settings/data/models/settings.dart'; import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; +import 'package:lensai/features/topics/domain/providers.dart'; +import 'package:lensai/features/topics/domain/repositories/tab.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:universal_io/io.dart'; @@ -43,3 +45,12 @@ Future readerabilityScript(ReaderabilityScriptRef ref) { ), ); } + +@Riverpod() +List activeTabs(ActiveTabsRef ref) { + final topic = ref.watch(selectedTopicProvider); + return ref.watch( + topicTabRepositoryProvider(topic) + .select((value) => value.valueOrNull ?? []), + ); +} diff --git a/app/lib/features/web_view/domain/providers.g.dart b/app/lib/features/web_view/domain/providers.g.dart index 40efcb69..21429702 100644 --- a/app/lib/features/web_view/domain/providers.g.dart +++ b/app/lib/features/web_view/domain/providers.g.dart @@ -37,5 +37,19 @@ final readerabilityScriptProvider = FutureProvider.internal( ); typedef ReaderabilityScriptRef = FutureProviderRef; +String _$activeTabsHash() => r'd1ef2ae32d8707f7cc0902dd031e8302a63f50d4'; + +/// See also [activeTabs]. +@ProviderFor(activeTabs) +final activeTabsProvider = AutoDisposeProvider>.internal( + activeTabs, + name: r'activeTabsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$activeTabsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef ActiveTabsRef = AutoDisposeProviderRef>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/web_view/domain/repositories/web_view.dart b/app/lib/features/web_view/domain/repositories/web_view.dart index e51644ce..071ab293 100644 --- a/app/lib/features/web_view/domain/repositories/web_view.dart +++ b/app/lib/features/web_view/domain/repositories/web_view.dart @@ -1,59 +1,179 @@ -import 'dart:collection'; - +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/core/logger.dart'; +import 'package:lensai/features/topics/data/providers.dart'; +import 'package:lensai/features/topics/domain/providers.dart'; +import 'package:lensai/features/topics/domain/repositories/tab.dart'; import 'package:lensai/features/web_view/domain/entities/consistent_controller.dart'; -import 'package:lensai/features/web_view/presentation/controllers/readerability.dart'; -import 'package:lensai/features/web_view/presentation/widgets/web_view.dart'; +import 'package:lensai/features/web_view/domain/entities/web_view_page.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'web_view.g.dart'; -@Riverpod(keepAlive: true) +abstract class _TabStateCopyProxy { + Future copyWith({ + InAppWebViewController? controller, + Uri? url, + SslError? sslError, + String? title, + String? topicId, + Favicon? favicon, + Uint8List? screenshot, + ({bool canGoBack, bool canGoForward})? pageHistory, + }); +} + +@Riverpod() class WebViewTabController extends _$WebViewTabController { - late Map _webViewTabs; + String? _requestedTab; @override - WebView? build() { - _webViewTabs = ref.watch(webViewRepositoryProvider); - return (stateOrNull != null) - ? _webViewTabs[stateOrNull!.tabId] ?? _webViewTabs.values.lastOrNull - : null; + String? build() { + final webViewTabs = ref.watch( + tabRepositoryProvider.select( + (value) => value.value ?? [], + ), + ); + + if (_requestedTab != null) { + if (webViewTabs.any((tab) => tab.id == _requestedTab)) { + return _requestedTab; + } else if (webViewTabs.isNotEmpty) { + final topic = ref.read(selectedTopicProvider); + return webViewTabs.where((tab) => tab.topicId == topic).lastOrNull?.id; + } + } + + return null; } void showTab(String? id) { - state = (id != null) ? _webViewTabs[id] : null; + _requestedTab = id; + ref.invalidateSelf(); } } -@Riverpod(keepAlive: true) -class WebViewRepository extends _$WebViewRepository { +@Riverpod() +class TabState extends _$TabState implements _TabStateCopyProxy { + KeepAliveLink? _aliveLink; + @override - Map build() { - return stateOrNull ?? {}; + WebViewPage? build(String tabId) { + final tabAsync = ref.watch(tabDataProvider(tabId)); + + if (tabAsync.hasValue) { + final tab = tabAsync.valueOrNull; + + final current = stateOrNull ?? + WebViewPage.create( + id: tabId, + url: tab?.url ?? Uri.https('localhost'), + ); + + if (tab != null) { + _aliveLink ??= ref.keepAlive(); + + return current.copyWith( + topicId: tab.topicId, + url: tab.url, + title: tab.title, + screenshot: tab.screenshot, + ); + } else { + _aliveLink?.close(); + _aliveLink = null; + + return current; + } + } + + return null; } - void _disposeReaderability(String id) { - final controller = state[id]?.currentController; - if (controller != null) { - ref - .read( - readerabilityControllerProvider(ConsistentController(controller)) - .notifier, - ) - .dispose(); + Future updateScreenshot() async { + final screenshot = await state?.controller + ?.takeScreenshot( + screenshotConfiguration: ScreenshotConfiguration( + compressFormat: CompressFormat.JPEG, + quality: 20, + ), + ) + .timeout( + const Duration(milliseconds: 1500), + onTimeout: () { + logger.w('Screenshot timed out'); + return null; + }, + ); + + await copyWith(screenshot: screenshot); + } + + @override + Future copyWith({ + Object? controller = const $CopyWithPlaceholder(), + Object? url = const $CopyWithPlaceholder(), + Object? sslError = const $CopyWithPlaceholder(), + Object? title = const $CopyWithPlaceholder(), + Object? topicId = const $CopyWithPlaceholder(), + Object? favicon = const $CopyWithPlaceholder(), + Object? screenshot = const $CopyWithPlaceholder(), + Object? pageHistory = const $CopyWithPlaceholder(), + }) async { + if (state != null) { + final current = state!; + + state = WebViewPage( + id: current.id, + controller: controller == const $CopyWithPlaceholder() + ? current.controller + : controller as InAppWebViewController?, + url: current.url, + sslError: sslError == const $CopyWithPlaceholder() + ? current.sslError + : sslError as SslError?, + title: current.title, + topicId: current.topicId, + favicon: favicon == const $CopyWithPlaceholder() + ? current.favicon + : favicon as Favicon?, + screenshot: current.screenshot, + pageHistory: + pageHistory == const $CopyWithPlaceholder() || pageHistory == null + ? current.pageHistory + : pageHistory as ({bool canGoBack, bool canGoForward}), + ); + + await ref.read(tabRepositoryProvider.notifier).updateTab( + current.id, + url: url == const $CopyWithPlaceholder() || url == null + ? const Value.absent() + : Value(url as Uri), + title: title == const $CopyWithPlaceholder() + ? const Value.absent() + : Value(title as String?), + topicId: topicId == const $CopyWithPlaceholder() + ? const Value.absent() + : Value(topicId as String?), + screenshot: screenshot == const $CopyWithPlaceholder() + ? const Value.absent() + : Value(screenshot as Uint8List?), + ); } } - - void addTab(WebView webView) { - state = {...state, webView.tabId: webView}; - } - - void closeTab(String id) { - _disposeReaderability(id); - state = Map.of(state)..remove(id); - } - - void closeAllTabs() { - state.keys.forEach(_disposeReaderability); - state = {}; - } +} + +@Riverpod() +InAppWebViewController? webViewController( + WebViewControllerRef ref, + String tabId, +) { + return ref + .watch( + tabStateProvider(tabId) + .select((value) => ConsistentController(value?.controller)), + ) + .value; } diff --git a/app/lib/features/web_view/domain/repositories/web_view.g.dart b/app/lib/features/web_view/domain/repositories/web_view.g.dart index d0ae0e0d..2d636a34 100644 --- a/app/lib/features/web_view/domain/repositories/web_view.g.dart +++ b/app/lib/features/web_view/domain/repositories/web_view.g.dart @@ -6,13 +6,164 @@ part of 'web_view.dart'; // RiverpodGenerator // ************************************************************************** +String _$webViewControllerHash() => r'f35fb13d46fcb04cee5676089f0cb8d5ca21e89b'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [webViewController]. +@ProviderFor(webViewController) +const webViewControllerProvider = WebViewControllerFamily(); + +/// See also [webViewController]. +class WebViewControllerFamily extends Family { + /// See also [webViewController]. + const WebViewControllerFamily(); + + /// See also [webViewController]. + WebViewControllerProvider call( + String tabId, + ) { + return WebViewControllerProvider( + tabId, + ); + } + + @override + WebViewControllerProvider getProviderOverride( + covariant WebViewControllerProvider provider, + ) { + return call( + provider.tabId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'webViewControllerProvider'; +} + +/// See also [webViewController]. +class WebViewControllerProvider + extends AutoDisposeProvider { + /// See also [webViewController]. + WebViewControllerProvider( + String tabId, + ) : this._internal( + (ref) => webViewController( + ref as WebViewControllerRef, + tabId, + ), + from: webViewControllerProvider, + name: r'webViewControllerProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$webViewControllerHash, + dependencies: WebViewControllerFamily._dependencies, + allTransitiveDependencies: + WebViewControllerFamily._allTransitiveDependencies, + tabId: tabId, + ); + + WebViewControllerProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.tabId, + }) : super.internal(); + + final String tabId; + + @override + Override overrideWith( + InAppWebViewController? Function(WebViewControllerRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: WebViewControllerProvider._internal( + (ref) => create(ref as WebViewControllerRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + tabId: tabId, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _WebViewControllerProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is WebViewControllerProvider && other.tabId == tabId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, tabId.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin WebViewControllerRef on AutoDisposeProviderRef { + /// The parameter `tabId` of this provider. + String get tabId; +} + +class _WebViewControllerProviderElement + extends AutoDisposeProviderElement + with WebViewControllerRef { + _WebViewControllerProviderElement(super.provider); + + @override + String get tabId => (origin as WebViewControllerProvider).tabId; +} + String _$webViewTabControllerHash() => - r'3f1d7460295c6399382881ee56c4ed7f1fd5fe03'; + r'cf1b766aa84472040b56a7b4d9ce1fd1606ed5e1'; /// See also [WebViewTabController]. @ProviderFor(WebViewTabController) final webViewTabControllerProvider = - NotifierProvider.internal( + AutoDisposeNotifierProvider.internal( WebViewTabController.new, name: r'webViewTabControllerProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -22,22 +173,146 @@ final webViewTabControllerProvider = allTransitiveDependencies: null, ); -typedef _$WebViewTabController = Notifier; -String _$webViewRepositoryHash() => r'dae5e1527ab4ad27f6a882fb63d8e88cd812b20b'; +typedef _$WebViewTabController = AutoDisposeNotifier; +String _$tabStateHash() => r'9ce6b0d99e5032f534c97482d6140dda4b14fb07'; -/// See also [WebViewRepository]. -@ProviderFor(WebViewRepository) -final webViewRepositoryProvider = - NotifierProvider>.internal( - WebViewRepository.new, - name: r'webViewRepositoryProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$webViewRepositoryHash, - dependencies: null, - allTransitiveDependencies: null, -); +abstract class _$TabState extends BuildlessAutoDisposeNotifier { + late final String tabId; -typedef _$WebViewRepository = Notifier>; + WebViewPage? build( + String tabId, + ); +} + +/// See also [TabState]. +@ProviderFor(TabState) +const tabStateProvider = TabStateFamily(); + +/// See also [TabState]. +class TabStateFamily extends Family { + /// See also [TabState]. + const TabStateFamily(); + + /// See also [TabState]. + TabStateProvider call( + String tabId, + ) { + return TabStateProvider( + tabId, + ); + } + + @override + TabStateProvider getProviderOverride( + covariant TabStateProvider provider, + ) { + return call( + provider.tabId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'tabStateProvider'; +} + +/// See also [TabState]. +class TabStateProvider + extends AutoDisposeNotifierProviderImpl { + /// See also [TabState]. + TabStateProvider( + String tabId, + ) : this._internal( + () => TabState()..tabId = tabId, + from: tabStateProvider, + name: r'tabStateProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabStateHash, + dependencies: TabStateFamily._dependencies, + allTransitiveDependencies: TabStateFamily._allTransitiveDependencies, + tabId: tabId, + ); + + TabStateProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.tabId, + }) : super.internal(); + + final String tabId; + + @override + WebViewPage? runNotifierBuild( + covariant TabState notifier, + ) { + return notifier.build( + tabId, + ); + } + + @override + Override overrideWith(TabState Function() create) { + return ProviderOverride( + origin: this, + override: TabStateProvider._internal( + () => create()..tabId = tabId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + tabId: tabId, + ), + ); + } + + @override + AutoDisposeNotifierProviderElement createElement() { + return _TabStateProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is TabStateProvider && other.tabId == tabId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, tabId.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin TabStateRef on AutoDisposeNotifierProviderRef { + /// The parameter `tabId` of this provider. + String get tabId; +} + +class _TabStateProviderElement + extends AutoDisposeNotifierProviderElement + with TabStateRef { + _TabStateProviderElement(super.provider); + + @override + String get tabId => (origin as TabStateProvider).tabId; +} // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/web_view/presentation/controllers/readerability.dart b/app/lib/features/web_view/presentation/controllers/readerability.dart index 75e986be..d94e1749 100644 --- a/app/lib/features/web_view/presentation/controllers/readerability.dart +++ b/app/lib/features/web_view/presentation/controllers/readerability.dart @@ -1,5 +1,5 @@ -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:lensai/features/web_view/domain/entities/consistent_controller.dart'; +import 'package:lensai/features/topics/data/providers.dart'; +import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; import 'package:lensai/features/web_view/presentation/services/readerability_script.dart'; import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -8,19 +8,25 @@ part 'readerability.g.dart'; @Riverpod() class ReaderabilityController extends _$ReaderabilityController { - late InAppWebViewController? _controller; late ReaderabilityScriptService _service; late KeepAliveLink _aliveLink; @override AsyncValue<({bool readerable, bool applied})> build( - ConsistentController controller, + String tabId, ) { - _controller = controller.value; - _service = - ref.watch(readerabilityScriptServiceProvider(controller).notifier); + _service = ref.watch(readerabilityScriptServiceProvider(tabId).notifier); _aliveLink = ref.keepAlive(); + ref.listen( + isTabExistingProvider(tabId), + (previous, next) { + if (next.valueOrNull == false) { + _aliveLink.close(); + } + }, + ); + return const AsyncLoading(); } @@ -43,7 +49,7 @@ class ReaderabilityController extends _$ReaderabilityController { state = const AsyncLoading(); state = await AsyncValue.guard(() async { if (applied) { - await _controller?.reload(); + await ref.read(webViewControllerProvider(tabId))?.reload(); } else { await _service.applyReaderable(); } @@ -55,8 +61,4 @@ class ReaderabilityController extends _$ReaderabilityController { void reset() { state = const AsyncLoading(); } - - void dispose() { - _aliveLink.close(); - } } diff --git a/app/lib/features/web_view/presentation/controllers/readerability.g.dart b/app/lib/features/web_view/presentation/controllers/readerability.g.dart index 6af3bf74..17e86e6d 100644 --- a/app/lib/features/web_view/presentation/controllers/readerability.g.dart +++ b/app/lib/features/web_view/presentation/controllers/readerability.g.dart @@ -7,7 +7,7 @@ part of 'readerability.dart'; // ************************************************************************** String _$readerabilityControllerHash() => - r'1237e57abce82f71dbc66b6ab46f50dad9e3ddf9'; + r'a1c8da3a9cf48300089e390a4fcae89862a6ec1a'; /// Copied from Dart SDK class _SystemHash { @@ -32,10 +32,10 @@ class _SystemHash { abstract class _$ReaderabilityController extends BuildlessAutoDisposeNotifier< AsyncValue<({bool readerable, bool applied})>> { - late final ConsistentController controller; + late final String tabId; AsyncValue<({bool readerable, bool applied})> build( - ConsistentController controller, + String tabId, ); } @@ -51,10 +51,10 @@ class ReaderabilityControllerFamily /// See also [ReaderabilityController]. ReaderabilityControllerProvider call( - ConsistentController controller, + String tabId, ) { return ReaderabilityControllerProvider( - controller, + tabId, ); } @@ -63,7 +63,7 @@ class ReaderabilityControllerFamily covariant ReaderabilityControllerProvider provider, ) { return call( - provider.controller, + provider.tabId, ); } @@ -87,9 +87,9 @@ class ReaderabilityControllerProvider extends AutoDisposeNotifierProviderImpl< ReaderabilityController, AsyncValue<({bool readerable, bool applied})>> { /// See also [ReaderabilityController]. ReaderabilityControllerProvider( - ConsistentController controller, + String tabId, ) : this._internal( - () => ReaderabilityController()..controller = controller, + () => ReaderabilityController()..tabId = tabId, from: readerabilityControllerProvider, name: r'readerabilityControllerProvider', debugGetCreateSourceHash: @@ -99,7 +99,7 @@ class ReaderabilityControllerProvider extends AutoDisposeNotifierProviderImpl< dependencies: ReaderabilityControllerFamily._dependencies, allTransitiveDependencies: ReaderabilityControllerFamily._allTransitiveDependencies, - controller: controller, + tabId: tabId, ); ReaderabilityControllerProvider._internal( @@ -109,17 +109,17 @@ class ReaderabilityControllerProvider extends AutoDisposeNotifierProviderImpl< required super.allTransitiveDependencies, required super.debugGetCreateSourceHash, required super.from, - required this.controller, + required this.tabId, }) : super.internal(); - final ConsistentController controller; + final String tabId; @override AsyncValue<({bool readerable, bool applied})> runNotifierBuild( covariant ReaderabilityController notifier, ) { return notifier.build( - controller, + tabId, ); } @@ -128,13 +128,13 @@ class ReaderabilityControllerProvider extends AutoDisposeNotifierProviderImpl< return ProviderOverride( origin: this, override: ReaderabilityControllerProvider._internal( - () => create()..controller = controller, + () => create()..tabId = tabId, from: from, name: null, dependencies: null, allTransitiveDependencies: null, debugGetCreateSourceHash: null, - controller: controller, + tabId: tabId, ), ); } @@ -147,14 +147,13 @@ class ReaderabilityControllerProvider extends AutoDisposeNotifierProviderImpl< @override bool operator ==(Object other) { - return other is ReaderabilityControllerProvider && - other.controller == controller; + return other is ReaderabilityControllerProvider && other.tabId == tabId; } @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, controller.hashCode); + hash = _SystemHash.combine(hash, tabId.hashCode); return _SystemHash.finish(hash); } @@ -162,8 +161,8 @@ class ReaderabilityControllerProvider extends AutoDisposeNotifierProviderImpl< mixin ReaderabilityControllerRef on AutoDisposeNotifierProviderRef< AsyncValue<({bool readerable, bool applied})>> { - /// The parameter `controller` of this provider. - ConsistentController get controller; + /// The parameter `tabId` of this provider. + String get tabId; } class _ReaderabilityControllerProviderElement @@ -173,8 +172,7 @@ class _ReaderabilityControllerProviderElement _ReaderabilityControllerProviderElement(super.provider); @override - ConsistentController get controller => - (origin as ReaderabilityControllerProvider).controller; + String get tabId => (origin as ReaderabilityControllerProvider).tabId; } // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/web_view/presentation/controllers/switch_new_tab.dart b/app/lib/features/web_view/presentation/controllers/switch_new_tab.dart index 76d9f1e8..4838a727 100644 --- a/app/lib/features/web_view/presentation/controllers/switch_new_tab.dart +++ b/app/lib/features/web_view/presentation/controllers/switch_new_tab.dart @@ -1,12 +1,13 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:lensai/features/topics/domain/providers.dart'; +import 'package:lensai/features/topics/domain/repositories/tab.dart'; import 'package:lensai/features/web_view/domain/entities/web_view_page.dart'; import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; -import 'package:lensai/features/web_view/presentation/widgets/web_view.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'switch_new_tab.g.dart'; -@riverpod +@Riverpod(keepAlive: true) class SwitchNewTabController extends _$SwitchNewTabController { @override FutureOr build() {} @@ -15,13 +16,15 @@ class SwitchNewTabController extends _$SwitchNewTabController { state = const AsyncLoading(); state = await AsyncValue.guard( () async { + final topic = ref.read(selectedTopicProvider); final newTab = WebViewPage.create( url: WebUri.uri(url), + topicId: topic, ); - ref - .read(webViewRepositoryProvider.notifier) - .addTab(WebView(tab: newTab)); + await ref + .read(topicTabRepositoryProvider(topic).notifier) + .addTab(newTab); ref.read(webViewTabControllerProvider.notifier).showTab(newTab.id); }, diff --git a/app/lib/features/web_view/presentation/controllers/switch_new_tab.g.dart b/app/lib/features/web_view/presentation/controllers/switch_new_tab.g.dart index 9e64e197..df257786 100644 --- a/app/lib/features/web_view/presentation/controllers/switch_new_tab.g.dart +++ b/app/lib/features/web_view/presentation/controllers/switch_new_tab.g.dart @@ -7,12 +7,12 @@ part of 'switch_new_tab.dart'; // ************************************************************************** String _$switchNewTabControllerHash() => - r'fb831015e4b99d3ae86bd2a88a3c6f4c7004783d'; + r'7ebfea953aeb81c279ea11254ff0ad558e6a949f'; /// See also [SwitchNewTabController]. @ProviderFor(SwitchNewTabController) final switchNewTabControllerProvider = - AutoDisposeAsyncNotifierProvider.internal( + AsyncNotifierProvider.internal( SwitchNewTabController.new, name: r'switchNewTabControllerProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -22,6 +22,6 @@ final switchNewTabControllerProvider = allTransitiveDependencies: null, ); -typedef _$SwitchNewTabController = AutoDisposeAsyncNotifier; +typedef _$SwitchNewTabController = AsyncNotifier; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/web_view/presentation/services/readerability_script.dart b/app/lib/features/web_view/presentation/services/readerability_script.dart index 22827934..79167da9 100644 --- a/app/lib/features/web_view/presentation/services/readerability_script.dart +++ b/app/lib/features/web_view/presentation/services/readerability_script.dart @@ -1,8 +1,8 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:lensai/features/settings/data/models/settings.dart'; import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/web_view/domain/entities/consistent_controller.dart'; import 'package:lensai/features/web_view/domain/providers.dart'; +import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'readerability_script.g.dart'; @@ -14,8 +14,8 @@ class ReaderabilityScriptService extends _$ReaderabilityScriptService { late bool _enableReadability; @override - Future build(ConsistentController controller) async { - _controller = controller.value; + Future build(String tabId) async { + _controller = ref.watch(webViewControllerProvider(tabId)); _readerabilityScript = ref.watch(readerabilityScriptProvider.future); _enableReadability = ref.watch( settingsRepositoryProvider.select( diff --git a/app/lib/features/web_view/presentation/services/readerability_script.g.dart b/app/lib/features/web_view/presentation/services/readerability_script.g.dart index e8d710eb..dc540b77 100644 --- a/app/lib/features/web_view/presentation/services/readerability_script.g.dart +++ b/app/lib/features/web_view/presentation/services/readerability_script.g.dart @@ -7,7 +7,7 @@ part of 'readerability_script.dart'; // ************************************************************************** String _$readerabilityScriptServiceHash() => - r'bb38e2934ed124502b70faed11b6921c40d0eec2'; + r'85f0de50516cadb1f2d1efa8161a8cf45868b9d9'; /// Copied from Dart SDK class _SystemHash { @@ -32,10 +32,10 @@ class _SystemHash { abstract class _$ReaderabilityScriptService extends BuildlessAutoDisposeAsyncNotifier { - late final ConsistentController controller; + late final String tabId; FutureOr build( - ConsistentController controller, + String tabId, ); } @@ -50,10 +50,10 @@ class ReaderabilityScriptServiceFamily extends Family> { /// See also [ReaderabilityScriptService]. ReaderabilityScriptServiceProvider call( - ConsistentController controller, + String tabId, ) { return ReaderabilityScriptServiceProvider( - controller, + tabId, ); } @@ -62,7 +62,7 @@ class ReaderabilityScriptServiceFamily extends Family> { covariant ReaderabilityScriptServiceProvider provider, ) { return call( - provider.controller, + provider.tabId, ); } @@ -87,9 +87,9 @@ class ReaderabilityScriptServiceProvider void> { /// See also [ReaderabilityScriptService]. ReaderabilityScriptServiceProvider( - ConsistentController controller, + String tabId, ) : this._internal( - () => ReaderabilityScriptService()..controller = controller, + () => ReaderabilityScriptService()..tabId = tabId, from: readerabilityScriptServiceProvider, name: r'readerabilityScriptServiceProvider', debugGetCreateSourceHash: @@ -99,7 +99,7 @@ class ReaderabilityScriptServiceProvider dependencies: ReaderabilityScriptServiceFamily._dependencies, allTransitiveDependencies: ReaderabilityScriptServiceFamily._allTransitiveDependencies, - controller: controller, + tabId: tabId, ); ReaderabilityScriptServiceProvider._internal( @@ -109,17 +109,17 @@ class ReaderabilityScriptServiceProvider required super.allTransitiveDependencies, required super.debugGetCreateSourceHash, required super.from, - required this.controller, + required this.tabId, }) : super.internal(); - final ConsistentController controller; + final String tabId; @override FutureOr runNotifierBuild( covariant ReaderabilityScriptService notifier, ) { return notifier.build( - controller, + tabId, ); } @@ -128,13 +128,13 @@ class ReaderabilityScriptServiceProvider return ProviderOverride( origin: this, override: ReaderabilityScriptServiceProvider._internal( - () => create()..controller = controller, + () => create()..tabId = tabId, from: from, name: null, dependencies: null, allTransitiveDependencies: null, debugGetCreateSourceHash: null, - controller: controller, + tabId: tabId, ), ); } @@ -147,14 +147,13 @@ class ReaderabilityScriptServiceProvider @override bool operator ==(Object other) { - return other is ReaderabilityScriptServiceProvider && - other.controller == controller; + return other is ReaderabilityScriptServiceProvider && other.tabId == tabId; } @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, controller.hashCode); + hash = _SystemHash.combine(hash, tabId.hashCode); return _SystemHash.finish(hash); } @@ -162,8 +161,8 @@ class ReaderabilityScriptServiceProvider mixin ReaderabilityScriptServiceRef on AutoDisposeAsyncNotifierProviderRef { - /// The parameter `controller` of this provider. - ConsistentController get controller; + /// The parameter `tabId` of this provider. + String get tabId; } class _ReaderabilityScriptServiceProviderElement @@ -172,8 +171,7 @@ class _ReaderabilityScriptServiceProviderElement _ReaderabilityScriptServiceProviderElement(super.provider); @override - ConsistentController get controller => - (origin as ReaderabilityScriptServiceProvider).controller; + String get tabId => (origin as ReaderabilityScriptServiceProvider).tabId; } // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart b/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart index d221ffcf..22f9b231 100644 --- a/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart +++ b/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart @@ -15,46 +15,22 @@ import 'package:lensai/features/settings/data/models/settings.dart'; import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart'; -import 'package:lensai/features/web_view/presentation/widgets/favicon.dart'; -import 'package:lensai/presentation/controllers/website_title.dart'; import 'package:lensai/presentation/widgets/failure_widget.dart'; +import 'package:lensai/presentation/widgets/website_title_tile.dart'; import 'package:lensai/utils/ui_helper.dart' as ui_helper; import 'package:lensai/utils/uri_parser.dart' as uri_parser; import 'package:share_plus/share_plus.dart'; -class LoadingWebPageDialog extends HookConsumerWidget { - final Uri url; - - final void Function()? onDismiss; - - const LoadingWebPageDialog(this.url, {this.onDismiss}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final pageInfoAsync = ref.watch(pageInfoProvider(url)); - - return pageInfoAsync.when( - data: (pageInfo) => WebPageDialog( - page: pageInfo, - onDismiss: onDismiss, - ), - error: (error, stackTrace) => SizedBox.shrink(), - loading: () => ModalBarrier( - color: Theme.of(context).dialogTheme.barrierColor ?? Colors.black54, - onDismiss: onDismiss, - ), - ); - } -} - class WebPageDialog extends HookConsumerWidget { - final WebPageInfo page; + final Uri url; + final WebPageInfo? precachedInfo; final InAppWebViewController? webViewController; final void Function()? onDismiss; const WebPageDialog({ - required this.page, + required this.url, + this.precachedInfo, this.webViewController, this.onDismiss, super.key, @@ -71,7 +47,7 @@ class WebPageDialog extends HookConsumerWidget { final availableBangsAsync = ref.watch( bangDataListProvider( filter: ( - domain: page.url.host, + domain: url.host, groups: null, categoryFilter: null, orderMostFrequentFirst: true, @@ -81,8 +57,7 @@ class WebPageDialog extends HookConsumerWidget { final availableBangCount = availableBangsAsync.valueOrNull?.length; final formKey = useMemoized(() => GlobalKey()); - final urlTextController = - useTextEditingController(text: page.url.toString()); + final urlTextController = useTextEditingController(text: url.toString()); return Stack( children: [ @@ -97,16 +72,7 @@ class WebPageDialog extends HookConsumerWidget { horizontal: 20.0, vertical: 24.0, ), - title: ListTile( - leading: FaviconImage( - favicon: page.favicon, - url: page.url, - size: 24, - ), - contentPadding: EdgeInsets.zero, - title: Text(page.title ?? 'Unknown Title'), - subtitle: Text(page.url.authority), - ), + title: WebsiteTitleTile(url, precachedInfo: precachedInfo), children: [ SizedBox( //We need this to stretch the dialog, then padding from dialog is applied @@ -164,7 +130,7 @@ class WebPageDialog extends HookConsumerWidget { } return SiteSearch( - domain: page.url.host, + domain: url.host, availableBangs: availableBangs, ); }, @@ -173,7 +139,7 @@ class WebPageDialog extends HookConsumerWidget { exception: error, ), loading: () => SiteSearch( - domain: page.url.host, + domain: url.host, availableBangs: [ BangData( websiteName: 'websiteName', @@ -194,14 +160,14 @@ class WebPageDialog extends HookConsumerWidget { title: const Text('Copy address'), onTap: () async { await Clipboard.setData( - ClipboardData(text: page.url.toString()), + ClipboardData(text: url.toString()), ); onDismiss?.call(); }, ), ListTile( onTap: () async { - await ui_helper.launchUrlFeedback(context, page.url); + await ui_helper.launchUrlFeedback(context, url); }, leading: const Icon(Icons.open_in_browser), title: const Text('Launch External'), @@ -212,7 +178,7 @@ class WebPageDialog extends HookConsumerWidget { onTap: () async { await ref .read(switchNewTabControllerProvider.notifier) - .add(page.url); + .add(url); onDismiss?.call(); }, @@ -221,7 +187,7 @@ class WebPageDialog extends HookConsumerWidget { leading: const Icon(Icons.share), title: const Text('Share link'), onTap: () async { - await Share.shareUri(page.url); + await Share.shareUri(url); onDismiss?.call(); }, @@ -231,14 +197,14 @@ class WebPageDialog extends HookConsumerWidget { leading: Icon(KagiTool.summarizer.icon), title: const Text('Summarize'), onTap: () async { - final url = uri_builder.summarizerUri( - document: SharedUrl(page.url), + final summarizerUrl = uri_builder.summarizerUri( + document: SharedUrl(url), mode: SummarizerMode.keyMoments, ); await ref .read(switchNewTabControllerProvider.notifier) - .add(url); + .add(summarizerUrl); onDismiss?.call(); }, diff --git a/app/lib/features/web_view/presentation/widgets/web_view.dart b/app/lib/features/web_view/presentation/widgets/web_view.dart index 64baaab0..7fc1d775 100644 --- a/app/lib/features/web_view/presentation/widgets/web_view.dart +++ b/app/lib/features/web_view/presentation/widgets/web_view.dart @@ -1,8 +1,6 @@ import 'dart:async'; import 'dart:convert'; -import 'package:copy_with_extension/copy_with_extension.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; @@ -17,9 +15,8 @@ import 'package:lensai/features/search_browser/domain/entities/sheet.dart'; import 'package:lensai/features/search_browser/domain/providers.dart'; import 'package:lensai/features/settings/data/models/settings.dart'; import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/web_view/domain/entities/consistent_controller.dart'; -import 'package:lensai/features/web_view/domain/entities/web_view_page.dart'; import 'package:lensai/features/web_view/domain/providers.dart'; +import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; import 'package:lensai/features/web_view/presentation/controllers/readerability.dart'; import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart'; import 'package:lensai/features/web_view/presentation/widgets/web_page_dialog.dart'; @@ -38,124 +35,15 @@ const _webViewSupportedSchemes = [ "about", ]; -abstract class _WebViewPageCWProxy { - WebViewPage controller(InAppWebViewController? controller); - - WebViewPage url(Uri url); - - WebViewPage sslError(SslError? sslError); - - WebViewPage title(String? title); - - WebViewPage topicId(String? topicId); - - WebViewPage favicon(Favicon? favicon); - - WebViewPage screenshot(Uint8List? screenshot); - - WebViewPage pageHistory(({bool canGoBack, bool canGoForward}) pageHistory); - - WebViewPage call({ - InAppWebViewController? controller, - Uri? url, - SslError? sslError, - String? title, - String? topicId, - Favicon? favicon, - Uint8List? screenshot, - ({bool canGoBack, bool canGoForward})? pageHistory, - }); -} - -class _WebViewPageCWProxyImpl implements _WebViewPageCWProxy { - const _WebViewPageCWProxyImpl(this._value); - - final WebViewPage _value; - - @override - WebViewPage controller(InAppWebViewController? controller) => - this(controller: controller); - - @override - WebViewPage url(Uri url) => this(url: url); - - @override - WebViewPage sslError(SslError? sslError) => this(sslError: sslError); - - @override - WebViewPage title(String? title) => this(title: title); - - @override - WebViewPage topicId(String? topicId) => this(topicId: topicId); - - @override - WebViewPage favicon(Favicon? favicon) => this(favicon: favicon); - - @override - WebViewPage screenshot(Uint8List? screenshot) => this(screenshot: screenshot); - - @override - WebViewPage pageHistory(({bool canGoBack, bool canGoForward}) pageHistory) => - this(pageHistory: pageHistory); - - @override - WebViewPage call({ - Object? controller = const $CopyWithPlaceholder(), - Object? url = const $CopyWithPlaceholder(), - Object? sslError = const $CopyWithPlaceholder(), - Object? title = const $CopyWithPlaceholder(), - Object? topicId = const $CopyWithPlaceholder(), - Object? favicon = const $CopyWithPlaceholder(), - Object? screenshot = const $CopyWithPlaceholder(), - Object? pageHistory = const $CopyWithPlaceholder(), - }) { - return WebViewPage( - key: _value.key, - id: _value.id, - controller: controller == const $CopyWithPlaceholder() - ? _value.controller - : controller as InAppWebViewController?, - url: url == const $CopyWithPlaceholder() || url == null - ? _value.url - : url as Uri, - sslError: sslError == const $CopyWithPlaceholder() - ? _value.sslError - : sslError as SslError?, - title: title == const $CopyWithPlaceholder() - ? _value.title - : title as String?, - topicId: topicId == const $CopyWithPlaceholder() - ? _value.topicId - : topicId as String?, - favicon: favicon == const $CopyWithPlaceholder() - ? _value.favicon - : favicon as Favicon?, - screenshot: screenshot == const $CopyWithPlaceholder() - ? _value.screenshot - : screenshot as Uint8List?, - pageHistory: - pageHistory == const $CopyWithPlaceholder() || pageHistory == null - ? _value.pageHistory - : pageHistory as ({bool canGoBack, bool canGoForward}), - ); - } -} - class WebView extends StatefulHookConsumerWidget { final String tabId; + final URLRequest? initialUrlRequest; - final ValueNotifier _pageNotifier; - - ValueListenable get page => _pageNotifier; - - /// Don't cache this value as it depends on current value of a ValueListenable - InAppWebViewController? get currentController => - _pageNotifier.value.controller; - - WebView({required WebViewPage tab}) - : _pageNotifier = ValueNotifier(tab), - tabId = tab.id, - super(key: tab.key); + const WebView({ + required this.tabId, + required super.key, + this.initialUrlRequest, + }); @override ConsumerState createState() => _WebViewState(); @@ -165,13 +53,6 @@ class _WebViewState extends ConsumerState { Timer? _onLoadStopDebounce; Timer? _periodicScreenshotUpdate; - void updatePage( - WebViewPage Function(_WebViewPageCWProxyImpl copyWith) update, - ) { - final x = widget._pageNotifier.value = - update(_WebViewPageCWProxyImpl(widget._pageNotifier.value)); - } - Future _downloadChat( DownloadStartRequest downloadStartRequest, BuildContext context, @@ -216,25 +97,6 @@ class _WebViewState extends ConsumerState { return false; } - Future _updateScreenshot() async { - final screenshot = await widget.page.value.controller - ?.takeScreenshot( - screenshotConfiguration: ScreenshotConfiguration( - compressFormat: CompressFormat.JPEG, - quality: 20, - ), - ) - .timeout( - const Duration(milliseconds: 1500), - onTimeout: () { - logger.w('Screenshot timed out'); - return null; - }, - ); - - updatePage((copyWith) => copyWith.screenshot(screenshot)); - } - @override Future dispose() async { super.dispose(); @@ -242,9 +104,7 @@ class _WebViewState extends ConsumerState { _onLoadStopDebounce?.cancel(); _periodicScreenshotUpdate?.cancel(); - widget._pageNotifier.dispose(); - - logger.i('Disposed ${widget.key} (${widget.page.value.title})'); + logger.i('Disposed ${widget.key} ${widget.tabId}'); } @override @@ -291,7 +151,8 @@ class _WebViewState extends ConsumerState { (value.valueOrNull ?? Settings.withDefaults()).enableJavascript, ), (previous, next) async { - await widget.page.value.controller?.setSettings( + final controller = ref.read(webViewControllerProvider(widget.tabId)); + await controller?.setSettings( settings: initialSettings.copy()..javaScriptEnabled = next, ); }, @@ -322,20 +183,22 @@ class _WebViewState extends ConsumerState { ); useOnAppLifecycleStateChange((previous, current) async { + final controller = ref.read(webViewControllerProvider(widget.tabId)); + switch (current) { case AppLifecycleState.paused: if (platform_util.isAndroid()) { - await widget.page.value.controller?.pause(); + await controller?.pause(); } if (platform_util.isAndroid() || platform_util.isIOS()) { - await widget.page.value.controller?.pauseTimers(); + await controller?.pauseTimers(); } case AppLifecycleState.resumed: if (platform_util.isAndroid()) { - await widget.page.value.controller?.resume(); + await controller?.resume(); } if (platform_util.isAndroid() || platform_util.isIOS()) { - await widget.page.value.controller?.resumeTimers(); + await controller?.resumeTimers(); } default: } @@ -344,7 +207,7 @@ class _WebViewState extends ConsumerState { return Stack( children: [ InAppWebView( - initialUrlRequest: URLRequest(url: WebUri.uri(widget.page.value.url)), + initialUrlRequest: widget.initialUrlRequest, initialSettings: initialSettings, findInteractionController: findInteractionController, contextMenu: ContextMenu( @@ -353,8 +216,10 @@ class _WebViewState extends ConsumerState { id: 1, title: "Search", action: () async { - final selectedText = - await widget.page.value.controller?.getSelectedText(); + final controller = + ref.read(webViewControllerProvider(widget.tabId)); + + final selectedText = await controller?.getSelectedText(); if (selectedText != null && selectedText.isNotEmpty) { final searchBang = @@ -371,8 +236,10 @@ class _WebViewState extends ConsumerState { id: 2, title: "Assistant", action: () async { - final selectedText = - await widget.page.value.controller?.getSelectedText(); + final controller = + ref.read(webViewControllerProvider(widget.tabId)); + + final selectedText = await controller?.getSelectedText(); if (selectedText != null && selectedText.isNotEmpty) { ref.read(bottomSheetProvider.notifier).show( @@ -391,19 +258,24 @@ class _WebViewState extends ConsumerState { await controller.startSafeBrowsing(); } - updatePage((copyWith) => copyWith.controller(controller)); + await ref + .read(tabStateProvider(widget.tabId).notifier) + .copyWith(controller: controller); }, onReceivedServerTrustAuthRequest: (controller, challenge) async { final sslError = challenge.protectionSpace.sslError; if (sslError != null && sslError.code != null) { - if (challenge.protectionSpace.host == - await controller.getUrl().then((value) => value?.host)) { - updatePage((copyWith) => copyWith.sslError(sslError)); + final url = await controller.getUrl(); + if (challenge.protectionSpace.host == url?.host) { + await ref + .read(tabStateProvider(widget.tabId).notifier) + .copyWith(sslError: sslError); + if (context.mounted) { ui_helper.showErrorMessage( context, - 'We detected an security issue and did not continue to ${widget.page.value.url.authority}: ${sslError.message}', + 'We detected an security issue and did not continue to ${url?.authority}: ${sslError.message}', ); } } @@ -414,7 +286,10 @@ class _WebViewState extends ConsumerState { ); } - updatePage((copyWith) => copyWith.sslError(null)); + await ref + .read(tabStateProvider(widget.tabId).notifier) + .copyWith(sslError: null); + return ServerTrustAuthResponse( action: ServerTrustAuthResponseAction.PROCEED, ); @@ -468,46 +343,48 @@ class _WebViewState extends ConsumerState { onProgressChanged: (controller, progress) { webViewProgress.value = progress; }, - onLoadStart: (controller, url) { + onLoadStart: (controller, url) async { final readabilityNotifier = ref.read( - readerabilityControllerProvider( - ConsistentController(controller), - ).notifier, + readerabilityControllerProvider(widget.tabId).notifier, ); if (url != null) { - updatePage( - (copyWith) => copyWith( - url: url, - // ignore: avoid_redundant_argument_values - sslError: null, - ), - ); + await ref.read(tabStateProvider(widget.tabId).notifier).copyWith( + url: url, + // ignore: avoid_redundant_argument_values + sslError: null, + ); } readabilityNotifier.reset(); }, onLoadStop: (controller, url) async { if (url != null) { - updatePage((copyWith) => copyWith.url(url)); + await ref + .read(tabStateProvider(widget.tabId).notifier) + .copyWith(url: url); } _onLoadStopDebounce?.cancel(); _onLoadStopDebounce = Timer(const Duration(milliseconds: 150), () async { final readabilityNotifier = ref.read( - readerabilityControllerProvider( - ConsistentController(controller), - ).notifier, + readerabilityControllerProvider(widget.tabId).notifier, ); await readabilityNotifier.checkReaderable(); - await _updateScreenshot().whenComplete(() { + await ref + .read(tabStateProvider(widget.tabId).notifier) + .updateScreenshot() + .whenComplete(() { _periodicScreenshotUpdate?.cancel(); _periodicScreenshotUpdate = Timer.periodic(const Duration(seconds: 5), (timer) async { - await _updateScreenshot().onError((error, stackTrace) { + await ref + .read(tabStateProvider(widget.tabId).notifier) + .updateScreenshot() + .onError((error, stackTrace) { logger.e(error, stackTrace: stackTrace); timer.cancel(); }); @@ -522,7 +399,9 @@ class _WebViewState extends ConsumerState { canGoForward: await controller.canGoForward() ); - updatePage((copyWith) => copyWith.pageHistory(history)); + await ref + .read(tabStateProvider(widget.tabId).notifier) + .copyWith(pageHistory: history); } }, shouldOverrideUrlLoading: (controller, navigationAction) async { @@ -582,8 +461,8 @@ class _WebViewState extends ConsumerState { Uri.tryParse(requestFocusNodeHrefResult?.src ?? ''); if (url?.hasScheme == true && url?.hasAuthority == true) { ref.read(overlayDialogProvider.notifier).show( - LoadingWebPageDialog( - url!, + WebPageDialog( + url: url!, onDismiss: () { ref.read(overlayDialogProvider.notifier).dismiss(); }, @@ -592,8 +471,10 @@ class _WebViewState extends ConsumerState { } } }, - onTitleChanged: (controller, title) { - updatePage((copyWith) => copyWith.title(title)); + onTitleChanged: (controller, title) async { + await ref + .read(tabStateProvider(widget.tabId).notifier) + .copyWith(title: title); }, onDownloadStartRequest: (controller, downloadStartRequest) async { final handled = switch (downloadStartRequest.mimeType) { diff --git a/app/lib/features/web_view/presentation/widgets/web_view_tab.dart b/app/lib/features/web_view/presentation/widgets/web_view_tab.dart index 90f3a1a7..49a4b977 100644 --- a/app/lib/features/web_view/presentation/widgets/web_view_tab.dart +++ b/app/lib/features/web_view/presentation/widgets/web_view_tab.dart @@ -1,23 +1,25 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/features/web_view/domain/entities/abstract/tab.dart'; -import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; import 'package:lensai/features/web_view/presentation/widgets/favicon.dart'; -class WebViewTab extends HookConsumerWidget { +class WebViewTab extends StatelessWidget { final ITab tab; final bool isActive; - final VoidCallback onClose; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final VoidCallback? onDelete; const WebViewTab({ required this.tab, required this.isActive, - required this.onClose, + this.onTap, + this.onLongPress, + this.onDelete, super.key, }); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Container( @@ -33,15 +35,8 @@ class WebViewTab extends HookConsumerWidget { borderRadius: const BorderRadius.all(Radius.circular(16.0)), child: InkWell( borderRadius: const BorderRadius.all(Radius.circular(16.0)), - onTap: () { - if (!isActive) { - //Close first to avoid rebuilds - onClose(); - ref.read(webViewTabControllerProvider.notifier).showTab(tab.id); - } else { - onClose(); - } - }, + onTap: onTap, + onLongPress: onLongPress, child: Column( children: [ Row( @@ -59,11 +54,7 @@ class WebViewTab extends HookConsumerWidget { IconButton( visualDensity: const VisualDensity(horizontal: -4.0, vertical: -4.0), - onPressed: () { - ref - .read(webViewRepositoryProvider.notifier) - .closeTab(tab.id); - }, + onPressed: onDelete, icon: const Icon(Icons.close), ), ], diff --git a/app/lib/presentation/widgets/selectable_chips.dart b/app/lib/presentation/widgets/selectable_chips.dart index ba8bc180..f97b7498 100644 --- a/app/lib/presentation/widgets/selectable_chips.dart +++ b/app/lib/presentation/widgets/selectable_chips.dart @@ -1,14 +1,33 @@ +import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; -class SelectableChips extends StatelessWidget { +class _BadgeWrapper extends StatelessWidget { + final Widget child; + final int? count; + + const _BadgeWrapper({required this.child, this.count}); + + @override + Widget build(BuildContext context) { + return count != null + ? Badge.count( + count: count!, + child: child, + ) + : child; + } +} + +class SelectableChips extends StatelessWidget { final List availableItems; - final T? selectedItem; + final S? selectedItem; final int maxCount; final bool deleteIcon; - final K Function(T item) itemId; + final K Function(S item) itemId; final Widget Function(T item) itemLabel; final Widget? Function(T item)? itemAvatar; + final int? Function(T item)? itemBadgeCount; final void Function(T item)? onSelected; final void Function(T item)? onDeleted; @@ -17,6 +36,7 @@ class SelectableChips extends StatelessWidget { required this.itemId, required this.itemLabel, this.itemAvatar, + this.itemBadgeCount, required this.availableItems, this.selectedItem, this.maxCount = 25, @@ -46,33 +66,42 @@ class SelectableChips extends StatelessWidget { } } - return ListView.builder( - scrollDirection: Axis.horizontal, - shrinkWrap: true, - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - return Padding( - padding: const EdgeInsets.only(right: 8.0), - child: FilterChip( - selected: selectedItem != null && - itemId(item) == itemId(selectedItem as T), - showCheckmark: false, - onSelected: (value) { - if (value) { - onSelected?.call(item); - } else { - onDeleted?.call(item); - } - }, - onDeleted: deleteIcon - ? () { - onDeleted?.call(item); - } - : null, - label: itemLabel.call(item), - avatar: itemAvatar?.call(item), - ), + return FadingScroll( + fadingSize: 15, + builder: (context, controller) { + return ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + shrinkWrap: true, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: _BadgeWrapper( + count: itemBadgeCount?.call(item), + child: FilterChip( + selected: selectedItem != null && + itemId(item) == itemId(selectedItem as S), + showCheckmark: false, + onSelected: (value) { + if (value) { + onSelected?.call(item); + } else { + onDeleted?.call(item); + } + }, + onDeleted: deleteIcon + ? () { + onDeleted?.call(item); + } + : null, + label: itemLabel.call(item), + avatar: itemAvatar?.call(item), + ), + ), + ); + }, ); }, ); diff --git a/app/lib/presentation/widgets/website_title_tile.dart b/app/lib/presentation/widgets/website_title_tile.dart index c96e5eec..f69cb9cc 100644 --- a/app/lib/presentation/widgets/website_title_tile.dart +++ b/app/lib/presentation/widgets/website_title_tile.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/domain/entities/web_page_info.dart'; import 'package:lensai/features/web_view/presentation/widgets/favicon.dart'; import 'package:lensai/presentation/controllers/website_title.dart'; import 'package:lensai/presentation/widgets/failure_widget.dart'; @@ -7,16 +8,17 @@ import 'package:skeletonizer/skeletonizer.dart'; class WebsiteTitleTile extends HookConsumerWidget { final Uri url; + final WebPageInfo? precachedInfo; - const WebsiteTitleTile(this.url, {super.key}); + const WebsiteTitleTile(this.url, {this.precachedInfo, super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final websiteTileAsync = ref.watch(pageInfoProvider(url)); + final pageInfoAsync = ref.watch(pageInfoProvider(url)); return Skeletonizer( - enabled: websiteTileAsync.isLoading, - child: websiteTileAsync.when( + enabled: pageInfoAsync.isLoading && precachedInfo == null, + child: pageInfoAsync.when( data: (info) { return ListTile( leading: FaviconImage( @@ -35,11 +37,22 @@ class WebsiteTitleTile extends HookConsumerWidget { onRetry: () => ref.refresh(pageInfoProvider(url)), ); }, - loading: () => const ListTile( - contentPadding: EdgeInsets.zero, - title: Bone.text(), - subtitle: Bone.text(), - ), + loading: () => (precachedInfo != null) + ? ListTile( + leading: FaviconImage( + favicon: precachedInfo!.favicon, + url: precachedInfo!.url, + size: 24, + ), + contentPadding: EdgeInsets.zero, + title: Text(precachedInfo!.title ?? 'Unknown Title'), + subtitle: Text(url.authority), + ) + : const ListTile( + contentPadding: EdgeInsets.zero, + title: Bone.text(), + subtitle: Bone.text(), + ), ), ); } diff --git a/app/pubspec.lock b/app/pubspec.lock index d0b4f1fb..9478de5d 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -330,10 +330,10 @@ packages: dependency: "direct main" description: name: fading_scroll - sha256: "0eeb846385950dfb03415bf76cebf087e7ec21b4fe28fd0c864ce334b6f56f02" + sha256: "70d13b031dc075d2cf395d599f381be7ea6759577cdfd88daa923d375522a9a2" url: "https://pub.dev" source: hosted - version: "0.9.0" + version: "0.9.1" fake_async: dependency: transitive description: @@ -495,10 +495,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "9d98bd47ef9d34e803d438f17fd32b116d31009f534a6fa5ce3a1167f189a6de" + sha256: "9ee02950848f61c4129af3d6ec84a1cfc0e47931abc746b03e7a3bc3e8ff6eda" url: "https://pub.dev" source: hosted - version: "2.0.21" + version: "2.0.22" flutter_riverpod: dependency: transitive description: @@ -601,10 +601,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: ddc16d34b0d74cb313986918c0f0885a7ba2fc24d8fb8419de75f0015144ccfe + sha256: "2ddb88e9ad56ae15ee144ed10e33886777eb5ca2509a914850a5faa7b52ff459" url: "https://pub.dev" source: hosted - version: "14.2.3" + version: "14.2.7" go_router_builder: dependency: "direct dev" description: @@ -825,10 +825,10 @@ packages: dependency: "direct main" description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.6" package_config: dependency: transitive description: @@ -1041,10 +1041,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: a7e8467e9181cef109f601e3f65765685786c1a738a83d7fbbde377589c0d974 + sha256: "480ba4345773f56acda9abf5f50bd966f581dac5d514e5fc4a18c62976bbba7e" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" shared_preferences_foundation: dependency: transitive description: @@ -1294,10 +1294,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: f0c73347dfcfa5b3db8bc06e1502668265d39c08f310c29bff4e28eea9699f79 + sha256: e35a698ac302dd68e41f73250bd9517fe3ab5fa4f18fe4647a0872db61bacbab url: "https://pub.dev" source: hosted - version: "6.3.9" + version: "6.3.10" url_launcher_ios: dependency: transitive description: @@ -1436,4 +1436,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.5.0 <4.0.0" - flutter: ">=3.22.0" + flutter: ">=3.24.0"