improved tab & topic handling

This commit is contained in:
Fabian Freund
2024-08-28 16:00:39 +02:00
parent 95dd254484
commit 0a7e84236f
43 changed files with 2035 additions and 847 deletions
@@ -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<FormState>());
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();
},
@@ -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<WebViewPage> _pageNotifier;
ValueListenable<WebViewPage> 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<ConsumerStatefulWidget> createState() => _WebViewState();
@@ -165,13 +53,6 @@ class _WebViewState extends ConsumerState<WebView> {
Timer? _onLoadStopDebounce;
Timer? _periodicScreenshotUpdate;
void updatePage(
WebViewPage Function(_WebViewPageCWProxyImpl copyWith) update,
) {
final x = widget._pageNotifier.value =
update(_WebViewPageCWProxyImpl(widget._pageNotifier.value));
}
Future<bool> _downloadChat(
DownloadStartRequest downloadStartRequest,
BuildContext context,
@@ -216,25 +97,6 @@ class _WebViewState extends ConsumerState<WebView> {
return false;
}
Future<void> _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<void> dispose() async {
super.dispose();
@@ -242,9 +104,7 @@ class _WebViewState extends ConsumerState<WebView> {
_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<WebView> {
(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<WebView> {
);
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<WebView> {
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<WebView> {
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<WebView> {
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<WebView> {
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<WebView> {
);
}
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<WebView> {
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<WebView> {
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<WebView> {
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<WebView> {
}
}
},
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) {
@@ -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),
),
],