Skip to content

Commit

Permalink
Add reader mode for in-app browser (#1184)
Browse files Browse the repository at this point in the history
* Add reader mode for in-app browser

* Fix dependency

* Remove dependency override that is no longer needed
  • Loading branch information
micahmo authored Oct 21, 2024
1 parent 9d46a6f commit f7e0af4
Show file tree
Hide file tree
Showing 7 changed files with 269 additions and 72 deletions.
4 changes: 4 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1747,6 +1747,10 @@
},
"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)"
Expand Down
24 changes: 12 additions & 12 deletions lib/settings/pages/general_settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -668,19 +668,19 @@ class _GeneralSettingsPageState extends State<GeneralSettingsPage> with SingleTi
highlightedSetting: settingToHighlight,
),
),
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: settingToHighlightKey,
setting: LocalSettings.openLinksInReaderMode,
highlightedSetting: settingToHighlight,
),
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: settingToHighlightKey,
setting: LocalSettings.openLinksInReaderMode,
highlightedSetting: settingToHighlight,
),
),
// TODO:(open_lemmy_links_walkthrough) maybe have the open lemmy links walkthrough here
if (!kIsWeb && Platform.isAndroid)
SliverToBoxAdapter(
Expand Down
28 changes: 21 additions & 7 deletions lib/settings/widgets/toggle_option.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ class ToggleOption extends StatelessWidget {
/// The highlighted setting, if any.
final LocalSettings? highlightedSetting;

/// Whether this setting can be changed by the user or not
final bool disabled;

const ToggleOption({
super.key,
required this.description,
Expand All @@ -78,6 +81,7 @@ class ToggleOption extends StatelessWidget {
required this.setting,
required this.highlightedSetting,
required this.highlightKey,
this.disabled = false,
});

void onTapInkWell() {
Expand All @@ -103,8 +107,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 ?? () => shareSetting(context, setting, description),
onTap: disabled
? null
: onToggle == null
? null
: onTapInkWell,
onLongPress: disabled
? null
: onToggle == null
? null
: onLongPress ?? () => shareSetting(context, setting, description),
child: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Row(
Expand Down Expand Up @@ -150,12 +162,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(
Expand Down
148 changes: 98 additions & 50 deletions lib/shared/webview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ 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/links.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;
Expand All @@ -23,57 +27,17 @@ class WebView extends StatefulWidget {
}

class _WebViewState extends State<WebView> {
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 <PlaybackMediaTypes>{},
);
} 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);
}

Expand All @@ -92,36 +56,114 @@ class _WebViewState extends State<WebView> {
return false;
}

void initWebController(BuildContext context) {
if (isControllerInit) return;

isControllerInit = true;
readerMode ??= context.read<ThunderBloc>().state.openInReaderMode;

late final PlatformWebViewControllerCreationParams params;

if (WebViewPlatform.instance is WebKitWebViewPlatform) {
params = WebKitWebViewControllerCreationParams(
allowsInlineMediaPlayback: true,
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
);
} 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(
appBar: AppBar(
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: <Widget>[
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) {
Expand Down Expand Up @@ -176,6 +218,12 @@ class NavigationControls extends StatelessWidget {
icon: Icons.link_rounded,
title: l10n.alternateSources,
),
ThunderPopupMenuItem(
onTap: onReaderModeToggled,
icon: Icons.menu_book_rounded,
title: l10n.readerMode,
trailing: readerMode ? const Icon(Icons.check_box_rounded) : const Icon(Icons.check_box_outline_blank_rounded),
),
],
),
const SizedBox(width: 8.0),
Expand Down
78 changes: 78 additions & 0 deletions lib/utils/web_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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<bool> canGoBack();
Future<bool> canGoForward();
Future<void> goBack();
Future<void> goForward();
Future<void> reload();
Future<String?> getTitle();
Future<String?> currentUrl();
Future<void> loadRequest(Uri uri);
}

class CustomWebViewController implements IWebController {
final WebViewController controller;

CustomWebViewController.fromWebViewController(this.controller);

@override
Future<bool> canGoBack() => controller.canGoBack();

@override
Future<bool> canGoForward() => controller.canGoForward();

@override
Future<void> goBack() => controller.goBack();

@override
Future<void> goForward() => controller.goForward();

@override
Future<void> reload() => controller.reload();

@override
Future<String?> getTitle() => controller.getTitle();

@override
Future<String?> currentUrl() => controller.currentUrl();

@override
Future<void> loadRequest(Uri uri) => controller.loadRequest(uri);
}

class CustomReaderModeController implements IWebController {
final ReaderModeController controller;

CustomReaderModeController.fromReaderModeController(this.controller);

@override
Future<bool> canGoBack() => Future.value(controller.canGoBack);

@override
Future<bool> canGoForward() => Future.value(controller.canGoForward);

@override
Future<void> goBack() async => controller.back();

@override
Future<void> goForward() async => controller.forward();

@override
Future<void> reload() {
return Future.value(() {
if (controller.uri != null) controller.loadUri(controller.uri!);
}());
}

@override
Future<String?> getTitle() => Future.value(controller.uri?.host.replaceFirst('www.', ''));

@override
Future<String?> currentUrl() => Future.value(controller.uri?.toString());

@override
Future<void> loadRequest(Uri uri) async => controller.loadUri(uri);
}
Loading

0 comments on commit f7e0af4

Please sign in to comment.