Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add reader mode for in-app browser #1184

Open
wants to merge 9 commits into
base: develop
Choose a base branch
from
4 changes: 4 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1455,6 +1455,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)"
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 @@ -627,19 +627,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,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;
Expand All @@ -22,57 +26,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 @@ -91,36 +55,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 @@ -162,6 +204,12 @@ class NavigationControls extends StatelessWidget {
icon: Icons.share_rounded,
title: l10n.share,
),
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
71 changes: 71 additions & 0 deletions lib/utils/web_utils.dart
Original file line number Diff line number Diff line change
@@ -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<bool> canGoBack();
Future<bool> canGoForward();
Future<void> goBack();
Future<void> goForward();
Future<void> reload();
Future<String?> getTitle();
Future<String?> currentUrl();
}

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();
}

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());
}
Loading
Loading