From 12d917acc2f9866f08909ddf81e439210b031a4c Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Mon, 11 Mar 2024 10:22:54 -0400 Subject: [PATCH 1/3] Add reader mode for in-app browser --- lib/l10n/app_en.arb | 4 + lib/settings/pages/general_settings_page.dart | 20 +-- lib/settings/widgets/toggle_option.dart | 28 +++- lib/shared/thunder_popup_menu_item.dart | 22 ++- lib/shared/webview.dart | 153 ++++++++++++------ lib/utils/web_utils.dart | 71 ++++++++ pubspec.lock | 53 +++++- pubspec.yaml | 3 + 8 files changed, 278 insertions(+), 76 deletions(-) create mode 100644 lib/utils/web_utils.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 133bd8193..9014ef19c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1081,6 +1081,10 @@ "@reachedTheBottom": {}, "readAll": "Read All", "@readAll": {}, + "readerMode": "Reader mode", + "@readerMode": { + "description": "Menu item for toggling reader mode" + }, "reason": "Reason", "@reason": { "description": "The reason for the moderation action (e.g., removing post)" diff --git a/lib/settings/pages/general_settings_page.dart b/lib/settings/pages/general_settings_page.dart index f583a8257..ffe1221f4 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -575,17 +575,17 @@ class _GeneralSettingsPageState extends State with SingleTi highlightKey: settingToHighlight == LocalSettings.browserMode ? settingToHighlightKey : null, ), ), - if (!kIsWeb && Platform.isIOS) - SliverToBoxAdapter( - child: ToggleOption( - description: l10n.openLinksInReaderMode, - value: openInReaderMode, - iconEnabled: Icons.menu_book_rounded, - iconDisabled: Icons.menu_book_rounded, - onToggle: (bool value) => setPreferences(LocalSettings.openLinksInReaderMode, value), - highlightKey: settingToHighlight == LocalSettings.openLinksInReaderMode ? settingToHighlightKey : null, - ), + SliverToBoxAdapter( + child: ToggleOption( + disabled: !((!kIsWeb && Platform.isIOS && browserMode == BrowserMode.customTabs) || (browserMode == BrowserMode.inApp)), + description: l10n.openLinksInReaderMode, + value: openInReaderMode, + iconEnabled: Icons.menu_book_rounded, + iconDisabled: Icons.menu_book_rounded, + onToggle: (bool value) => setPreferences(LocalSettings.openLinksInReaderMode, value), + highlightKey: settingToHighlight == LocalSettings.openLinksInReaderMode ? settingToHighlightKey : null, ), + ), // TODO:(open_lemmy_links_walkthrough) maybe have the open lemmy links walkthrough here if (!kIsWeb && Platform.isAndroid) SliverToBoxAdapter( diff --git a/lib/settings/widgets/toggle_option.dart b/lib/settings/widgets/toggle_option.dart index ac4a1b5d9..93468d6a3 100644 --- a/lib/settings/widgets/toggle_option.dart +++ b/lib/settings/widgets/toggle_option.dart @@ -51,6 +51,9 @@ class ToggleOption extends StatelessWidget { /// Override the default padding final EdgeInsets? padding; + /// Whether this setting can be changed by the user or not + final bool disabled; + const ToggleOption({ super.key, required this.description, @@ -68,6 +71,7 @@ class ToggleOption extends StatelessWidget { this.onLongPress, this.highlightKey, this.padding, + this.disabled = false, }); void onTapInkWell() { @@ -93,8 +97,16 @@ class ToggleOption extends StatelessWidget { label: semanticLabel ?? description, child: InkWell( borderRadius: const BorderRadius.all(Radius.circular(50)), - onTap: onToggle == null ? null : onTapInkWell, - onLongPress: onToggle == null ? null : () => onLongPress?.call(), + onTap: disabled + ? null + : onToggle == null + ? null + : onTapInkWell, + onLongPress: disabled + ? null + : onToggle == null + ? null + : () => onLongPress?.call(), child: Padding( padding: const EdgeInsets.only(left: 4.0), child: Row( @@ -140,12 +152,14 @@ class ToggleOption extends StatelessWidget { if (value != null) Switch( value: value!, - onChanged: onToggle == null + onChanged: disabled ? null - : (bool value) { - HapticFeedback.lightImpact(); - onToggle?.call(value); - }, + : onToggle == null + ? null + : (bool value) { + HapticFeedback.lightImpact(); + onToggle?.call(value); + }, ), if (value == null) const SizedBox( diff --git a/lib/shared/thunder_popup_menu_item.dart b/lib/shared/thunder_popup_menu_item.dart index 2956be4c8..bc99fd763 100644 --- a/lib/shared/thunder_popup_menu_item.dart +++ b/lib/shared/thunder_popup_menu_item.dart @@ -4,14 +4,22 @@ import 'package:flutter/material.dart'; class ThunderPopupMenuItem extends PopupMenuItem { final IconData icon; final String title; + final bool? checkboxValue; + final void Function()? onCheckboxValueToggled; - ThunderPopupMenuItem({super.key, required super.onTap, required this.icon, required this.title}) - : super( + ThunderPopupMenuItem({ + super.key, + required super.onTap, + required this.icon, + required this.title, + this.checkboxValue, + this.onCheckboxValueToggled, + }) : super( child: ListTile( - dense: true, - horizontalTitleGap: 5, - leading: Icon(icon, size: 20), - title: Text(title), - ), + dense: true, + horizontalTitleGap: 5, + leading: Icon(icon, size: 20), + title: Text(title), + trailing: checkboxValue == null ? null : Checkbox(value: checkboxValue, onChanged: (_) => onCheckboxValueToggled?.call())), ); } diff --git a/lib/shared/webview.dart b/lib/shared/webview.dart index bfd5c7731..ae83bf6b3 100644 --- a/lib/shared/webview.dart +++ b/lib/shared/webview.dart @@ -4,14 +4,18 @@ import 'dart:io'; import 'package:back_button_interceptor/back_button_interceptor.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:share_plus/share_plus.dart'; import 'package:thunder/shared/thunder_popup_menu_item.dart'; +import 'package:thunder/thunder/bloc/thunder_bloc.dart'; +import 'package:thunder/utils/web_utils.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:xayn_readability/xayn_readability.dart'; class WebView extends StatefulWidget { final String url; @@ -22,57 +26,17 @@ class WebView extends StatefulWidget { } class _WebViewState extends State { - late final WebViewController _controller; + late IWebController _controller; // Keeps track of the URL that we are currently viewing, not necessarily the original String? currentUrl; + bool? readerMode; + bool isControllerInit = false; + @override void initState() { super.initState(); - - late final PlatformWebViewControllerCreationParams params; - - if (WebViewPlatform.instance is WebKitWebViewPlatform) { - params = WebKitWebViewControllerCreationParams( - allowsInlineMediaPlayback: true, - mediaTypesRequiringUserAction: const {}, - ); - } else { - params = const PlatformWebViewControllerCreationParams(); - } - - final WebViewController controller = WebViewController.fromPlatformCreationParams(params); - - controller - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setNavigationDelegate(NavigationDelegate()) - ..loadRequest(Uri.parse(widget.url)) - ..setNavigationDelegate(NavigationDelegate( - onNavigationRequest: (navigationRequest) { - if (!kIsWeb && Platform.isAndroid) { - Uri? uri = Uri.tryParse(navigationRequest.url); - - // Check if the scheme is not https, in which case the in-app browser can't handle it - if (uri != null && uri.scheme != 'https') { - // Although a non-https scheme is an indication that this link is intended for another app, - // we actually have to change it back to https in order for the intent to be properly passed to another app. - launchUrl(uri.replace(scheme: 'https'), mode: LaunchMode.externalApplication); - - // Finally, navigate back to the previous URL. - return NavigationDecision.prevent; - } - } - return NavigationDecision.navigate; - }, - onUrlChange: (urlChange) => setState(() => currentUrl = urlChange.url), - )); - - if (controller.platform is AndroidWebViewController) { - (controller.platform as AndroidWebViewController).setMediaPlaybackRequiresUserGesture(false); - } - _controller = controller; - BackButtonInterceptor.add(_handleBack); } @@ -91,8 +55,66 @@ class _WebViewState extends State { return false; } + void initWebController(BuildContext context) { + if (isControllerInit) return; + + isControllerInit = true; + readerMode ??= context.read().state.openInReaderMode; + + late final PlatformWebViewControllerCreationParams params; + + if (WebViewPlatform.instance is WebKitWebViewPlatform) { + params = WebKitWebViewControllerCreationParams( + allowsInlineMediaPlayback: true, + mediaTypesRequiringUserAction: const {}, + ); + } else { + params = const PlatformWebViewControllerCreationParams(); + } + + if (readerMode == true) { + ReaderModeController controller = ReaderModeController()..loadUri(Uri.parse(widget.url)); + controller.addListener(() { + setState(() => currentUrl = controller.uri?.toString()); + }); + _controller = CustomReaderModeController.fromReaderModeController(controller); + } else { + final WebViewController controller = WebViewController.fromPlatformCreationParams(params); + + controller + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..loadRequest(Uri.parse(widget.url)) + ..setNavigationDelegate(NavigationDelegate( + onNavigationRequest: (navigationRequest) { + if (!kIsWeb && Platform.isAndroid) { + Uri? uri = Uri.tryParse(navigationRequest.url); + + // Check if the scheme is not https, in which case the in-app browser can't handle it + if (uri != null && uri.scheme != 'https') { + // Although a non-https scheme is an indication that this link is intended for another app, + // we actually have to change it back to https in order for the intent to be properly passed to another app. + launchUrl(uri.replace(scheme: 'https'), mode: LaunchMode.externalApplication); + + // Finally, navigate back to the previous URL. + return NavigationDecision.prevent; + } + } + return NavigationDecision.navigate; + }, + onUrlChange: (urlChange) => setState(() => currentUrl = urlChange.url), + )); + + if (controller.platform is AndroidWebViewController) { + (controller.platform as AndroidWebViewController).setMediaPlaybackRequiresUserGesture(false); + } + _controller = CustomWebViewController.fromWebViewController(controller); + } + } + @override Widget build(BuildContext context) { + initWebController(context); + return FutureBuilder( future: Future.wait([_controller.getTitle(), _controller.currentUrl()]), builder: (context, snapshot) => Scaffold( @@ -100,27 +122,47 @@ class _WebViewState extends State { toolbarHeight: 70.0, titleSpacing: 0, title: ListTile( - title: Text(snapshot.data?[0] ?? '', maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text(snapshot.data?[1]?.replaceFirst('https://', '').replaceFirst('www.', '') ?? '', maxLines: 1, overflow: TextOverflow.ellipsis), + title: Text(snapshot.data?[0] ?? snapshot.data?[1] ?? '', overflow: TextOverflow.fade, softWrap: false), + subtitle: Text(snapshot.data?[1]?.replaceFirst('https://', '').replaceFirst('www.', '') ?? '', overflow: TextOverflow.fade, softWrap: false), ), actions: [ NavigationControls( webViewController: _controller, url: currentUrl ?? widget.url, + readerMode: readerMode!, + onReaderModeToggled: () { + isControllerInit = false; + readerMode = !readerMode!; + initWebController(context); + setState(() {}); + }, ) ], ), - body: WebViewWidget(controller: _controller), + body: readerMode == true + ? ReaderMode( + controller: (_controller as CustomReaderModeController).controller, + rendererPadding: const EdgeInsets.all(16.0), + ) + : WebViewWidget(controller: (_controller as CustomWebViewController).controller), ), ); } } class NavigationControls extends StatelessWidget { - const NavigationControls({super.key, required this.webViewController, required this.url}); - - final WebViewController webViewController; + const NavigationControls({ + super.key, + required this.webViewController, + required this.url, + required this.readerMode, + required this.onReaderModeToggled, + }); + + final IWebController webViewController; final String url; + final bool readerMode; + final void Function() onReaderModeToggled; @override Widget build(BuildContext context) { @@ -162,6 +204,17 @@ class NavigationControls extends StatelessWidget { icon: Icons.share_rounded, title: l10n.share, ), + ThunderPopupMenuItem( + onTap: onReaderModeToggled, + icon: Icons.menu_book_rounded, + title: l10n.readerMode, + checkboxValue: readerMode, + onCheckboxValueToggled: () { + // Have to manually close the popup menu + Navigator.pop(context); + onReaderModeToggled(); + }, + ), ], ), const SizedBox(width: 8.0), diff --git a/lib/utils/web_utils.dart b/lib/utils/web_utils.dart new file mode 100644 index 000000000..1f2ae9d00 --- /dev/null +++ b/lib/utils/web_utils.dart @@ -0,0 +1,71 @@ +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:xayn_readability/xayn_readability.dart'; + +/// Defines an interface which can perform web controlling operations +abstract interface class IWebController { + Future canGoBack(); + Future canGoForward(); + Future goBack(); + Future goForward(); + Future reload(); + Future getTitle(); + Future currentUrl(); +} + +class CustomWebViewController implements IWebController { + final WebViewController controller; + + CustomWebViewController.fromWebViewController(this.controller); + + @override + Future canGoBack() => controller.canGoBack(); + + @override + Future canGoForward() => controller.canGoForward(); + + @override + Future goBack() => controller.goBack(); + + @override + Future goForward() => controller.goForward(); + + @override + Future reload() => controller.reload(); + + @override + Future getTitle() => controller.getTitle(); + + @override + Future currentUrl() => controller.currentUrl(); +} + +class CustomReaderModeController implements IWebController { + final ReaderModeController controller; + + CustomReaderModeController.fromReaderModeController(this.controller); + + @override + Future canGoBack() => Future.value(controller.canGoBack); + + @override + Future canGoForward() => Future.value(controller.canGoForward); + + @override + Future goBack() async => controller.back(); + + @override + Future goForward() async => controller.forward(); + + @override + Future reload() { + return Future.value(() { + if (controller.uri != null) controller.loadUri(controller.uri!); + }()); + } + + @override + Future getTitle() => Future.value(controller.uri?.host.replaceFirst('www.', '')); + + @override + Future currentUrl() => Future.value(controller.uri?.toString()); +} diff --git a/pubspec.lock b/pubspec.lock index 18c9c80e6..29a2900f8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + buffer: + dependency: transitive + description: + name: buffer + sha256: "94f60815065a8f0fd4f05be51faf86cf86519327e039d5c2aac72e1d1cc1dad4" + url: "https://pub.dev" + source: hosted + version: "1.2.2" build: dependency: transitive description: @@ -293,10 +301,10 @@ packages: dependency: transitive description: name: csslib - sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + sha256: "831883fb353c8bdc1d71979e5b342c7d88acfbc643113c14ae51e2442ea0f20f" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "0.17.3" cupertino_icons: dependency: "direct main" description: @@ -393,6 +401,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.5" + executor: + dependency: transitive + description: + name: executor + sha256: "87d935b31b2bdf7fe2af0b77dd8ffe8864eaade25715e9c68bc49497e48c9851" + url: "https://pub.dev" + source: hosted + version: "2.2.3" exif: dependency: "direct main" description: @@ -749,6 +765,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_widget_from_html_core: + dependency: transitive + description: + name: flutter_widget_from_html_core + sha256: "296485e685b44ad7c6883372aebc56038a28b84f75e7a668bcd8bf01de881739" + url: "https://pub.dev" + source: hosted + version: "0.9.1" freezed_annotation: dependency: transitive description: @@ -765,6 +789,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" + fwfh_text_style: + dependency: transitive + description: + name: fwfh_text_style + sha256: "5f8b587fd223a6bf14aad3d3da5e7ced0628becbd0768f8e7ae25ff6b9f3d2ec" + url: "https://pub.dev" + source: hosted + version: "2.23.8" gal: dependency: "direct main" description: @@ -821,6 +853,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + http_client: + dependency: transitive + description: + name: http_client + sha256: f62a0ec75b41e75acb228eee759d4a838086449548f1dd78ac0751b551f9a632 + url: "https://pub.dev" + source: hosted + version: "1.5.3" http_client_helper: dependency: transitive description: @@ -1855,6 +1895,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + xayn_readability: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "57004de3575084725173dbfeb60c57595c3721d3" + url: "https://github.com/xaynetwork/xayn_readability" + source: git + version: "0.0.1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 56cbd3852..4f8364a5d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -109,6 +109,9 @@ dependencies: gal: ^2.2.0 smooth_highlight: ^0.1.1 visibility_detector: ^0.4.0+2 + xayn_readability: + git: + url: https://github.com/xaynetwork/xayn_readability dev_dependencies: build_runner: ^2.4.6 From 7f7557e1e64d87f251a66870561092848e1459f7 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Thu, 16 May 2024 11:57:44 -0400 Subject: [PATCH 2/3] Fix dependency --- pubspec.lock | 10 +--------- pubspec.yaml | 3 +++ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 1acc53547..4fbb4e6bb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -823,7 +823,7 @@ packages: source: sdk version: "0.0.0" flutter_widget_from_html_core: - dependency: transitive + dependency: "direct overridden" description: name: flutter_widget_from_html_core sha256: "028f4989b9ff4907466af233d50146d807772600d98a3e895662fbdb09c39225" @@ -846,14 +846,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" - fwfh_text_style: - dependency: transitive - description: - name: fwfh_text_style - sha256: "5f8b587fd223a6bf14aad3d3da5e7ced0628becbd0768f8e7ae25ff6b9f3d2ec" - url: "https://pub.dev" - source: hosted - version: "2.23.8" gal: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2b26838d0..14b13f425 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -134,6 +134,9 @@ dependency_overrides: url: https://github.com/hjiangsu/push.git ref: 70269bd08a06c4f34f6f192a2d46127ae48479a5 path: push_android + # Fixes mismatch between river_player and xayn_readability dependencies + # If/when we host xayn_readability ourselves, we can upgrade the dependency there and remove this. + flutter_widget_from_html_core: ^0.14.11 # The following section is specific to Flutter packages. flutter: From e44cd2af3deae9a8e9ed0796cebc7e53f724b704 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Wed, 25 Sep 2024 12:42:00 -0400 Subject: [PATCH 3/3] Remove dependency override that is no longer needed --- pubspec.lock | 18 +++++++++++++----- pubspec.yaml | 5 +---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 3db7be67f..fa8ea72e7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -330,10 +330,10 @@ packages: dependency: transitive description: name: csslib - sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + sha256: "831883fb353c8bdc1d71979e5b342c7d88acfbc643113c14ae51e2442ea0f20f" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "0.17.3" cupertino_icons: dependency: "direct main" description: @@ -884,13 +884,13 @@ packages: source: sdk version: "0.0.0" flutter_widget_from_html_core: - dependency: "direct overridden" + dependency: transitive description: name: flutter_widget_from_html_core - sha256: cc1d9be3d187ce668ee02091cd5442dfb050cdaf98e0ab9a4d12ad008f966979 + sha256: "296485e685b44ad7c6883372aebc56038a28b84f75e7a668bcd8bf01de881739" url: "https://pub.dev" source: hosted - version: "0.14.12" + version: "0.9.1" freezed_annotation: dependency: transitive description: @@ -907,6 +907,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fwfh_text_style: + dependency: transitive + description: + name: fwfh_text_style + sha256: "5f8b587fd223a6bf14aad3d3da5e7ced0628becbd0768f8e7ae25ff6b9f3d2ec" + url: "https://pub.dev" + source: hosted + version: "2.23.8" gal: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index f5e3f4594..6d6d26e5d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -125,10 +125,7 @@ dev_dependencies: ref: master drift_dev: ^2.16.0 -dependency_overrides: - # Fixes mismatch between river_player and xayn_readability dependencies - # If/when we host xayn_readability ourselves, we can upgrade the dependency there and remove this. - flutter_widget_from_html_core: ^0.14.11 +#dependency_overrides: # The following section is specific to Flutter packages. flutter: