diff --git a/lib/screens/item/item_screen.dart b/lib/screens/item/item_screen.dart index ef3e697b..117b766f 100644 --- a/lib/screens/item/item_screen.dart +++ b/lib/screens/item/item_screen.dart @@ -177,8 +177,8 @@ class _ItemScreenState extends State AppDurations.oneSecond; static const double _indentPadding = 8; static const double _indentLineWidth = 2; - static const double _webViewOffsetInvisible = 0.1; - bool _isWebViewBottomSheetVisible = true; + final ItemScreenWebViewController _webViewController = + ItemScreenWebViewController(); @override void didPop() { @@ -220,6 +220,7 @@ class _ItemScreenState extends State featureDiscoveryDismissThrottle.dispose(); focusNode.dispose(); scrollOffsetSubscription?.cancel(); + _webViewController.dispose(); super.dispose(); } @@ -229,8 +230,15 @@ class _ItemScreenState extends State .read() .state .isOfflineReading; + final bool isWebViewBottomSheetEnabled = context + .select( + (PreferenceCubit cubit) => cubit.state.isWebViewBottomSheetEnabled, + ); final bool shouldShowWebViewBottomSheet = - !isOfflineReading && widget.item is Story && widget.item.url.isNotEmpty; + !isOfflineReading && + isWebViewBottomSheetEnabled && + widget.item is Story && + widget.item.url.isNotEmpty; return MultiBlocListener( listeners: >[ BlocListener( @@ -312,11 +320,7 @@ class _ItemScreenState extends State cmt, context.read().state.item, ), - onStoryUrlTapped: () { - setState(() { - _isWebViewBottomSheetVisible = true; - }); - }, + onStoryUrlTapped: _webViewController.show, shouldMarkNewComment: widget.shouldMarkNewComment, ), ), @@ -354,7 +358,11 @@ class _ItemScreenState extends State bottom: Dimens.pt36, child: FloatingSkipButtons(), ), - if (shouldShowWebViewBottomSheet) webViewBottomSheet, + if (shouldShowWebViewBottomSheet) + ItemScreenWebView( + url: widget.item.url, + controller: _webViewController, + ), Positioned( bottom: Dimens.zero, left: Dimens.zero, @@ -426,11 +434,7 @@ class _ItemScreenState extends State cmt, context.read().state.item, ), - onStoryUrlTapped: () { - setState(() { - _isWebViewBottomSheetVisible = true; - }); - }, + onStoryUrlTapped: _webViewController.show, shouldMarkNewComment: widget.shouldMarkNewComment, ), ), @@ -465,37 +469,16 @@ class _ItemScreenState extends State context.read().scrollTo(index: 0), ), ), - if (shouldShowWebViewBottomSheet) webViewBottomSheet, + if (shouldShowWebViewBottomSheet) + ItemScreenWebView( + url: widget.item.url, + controller: _webViewController, + ), ], ), ); } - Widget get webViewBottomSheet => Positioned.fill( - child: AnimatedSlide( - offset: Offset( - 0, - _isWebViewBottomSheetVisible ? 0 : _webViewOffsetInvisible, - ), - duration: AppDurations.ms200, - child: WebViewBottomSheet( - initialUrl: widget.item.url, - onDragHandleTapped: () { - if (!_isWebViewBottomSheetVisible) { - setState(() { - _isWebViewBottomSheetVisible = true; - }); - } - }, - onCloseTapped: () { - setState(() { - _isWebViewBottomSheetVisible = !_isWebViewBottomSheetVisible; - }); - }, - ), - ), - ); - void removeReplyBoxFocusOnScroll(double _) { focusNode.unfocus(); if (commentEditingController.text.isEmpty) { diff --git a/lib/screens/item/widgets/item_screen_web_view.dart b/lib/screens/item/widgets/item_screen_web_view.dart new file mode 100644 index 00000000..645ba884 --- /dev/null +++ b/lib/screens/item/widgets/item_screen_web_view.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:hacki/config/constants.dart'; +import 'package:hacki/screens/widgets/widgets.dart'; + +/// Controls the visibility of an [ItemScreenWebView]. +/// +/// Held by the owning screen so the sheet can be revealed both from within +/// (drag handle / close button) and from the outside (e.g. tapping a story's +/// url in the header). +class ItemScreenWebViewController extends ChangeNotifier { + bool _isVisible = true; + + bool get isVisible => _isVisible; + + /// Reveals the sheet if it is currently hidden. + void show() { + if (_isVisible) return; + _isVisible = true; + notifyListeners(); + } + + /// Flips the sheet between visible and hidden. + void toggle() { + _isVisible = !_isVisible; + notifyListeners(); + } +} + +/// The draggable web view bottom sheet shown on the item screen for a story's +/// linked url. It slides just out of view when hidden instead of being removed +/// from the tree, so its state is preserved. +class ItemScreenWebView extends StatelessWidget { + const ItemScreenWebView({ + required this.url, + required this.controller, + super.key, + }); + + final String url; + final ItemScreenWebViewController controller; + + static const double _offsetInvisible = 0.1; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: controller, + builder: (BuildContext context, Widget? child) { + return Positioned.fill( + child: AnimatedSlide( + offset: Offset(0, controller.isVisible ? 0 : _offsetInvisible), + duration: AppDurations.ms200, + child: WebViewBottomSheet( + initialUrl: url, + isVisible: controller.isVisible, + onDragHandleTapped: controller.show, + onCloseTapped: controller.toggle, + ), + ), + ); + }, + ); + } +} diff --git a/lib/screens/item/widgets/widgets.dart b/lib/screens/item/widgets/widgets.dart index 0ac53f8e..850634e5 100644 --- a/lib/screens/item/widgets/widgets.dart +++ b/lib/screens/item/widgets/widgets.dart @@ -3,6 +3,7 @@ export 'custom_floating_action_button.dart'; export 'fav_icon_button.dart'; export 'in_thread_search_icon_button.dart'; export 'item_screen_background.dart'; +export 'item_screen_web_view.dart'; export 'lazy_fetch_load_button.dart'; export 'login_dialog.dart'; export 'main_view.dart'; diff --git a/lib/screens/widgets/web_view_bottom_sheet.dart b/lib/screens/widgets/web_view_bottom_sheet.dart index 85a59e9e..12428aa2 100644 --- a/lib/screens/widgets/web_view_bottom_sheet.dart +++ b/lib/screens/widgets/web_view_bottom_sheet.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hacki/config/constants.dart'; -import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/screens/widgets/spring_curve.dart'; import 'package:hacki/styles/dimens.dart'; import 'package:hacki/styles/palette.dart'; @@ -11,12 +9,14 @@ import 'package:webview_flutter/webview_flutter.dart'; class WebViewBottomSheet extends StatefulWidget { const WebViewBottomSheet({ required this.initialUrl, + required this.isVisible, required this.onCloseTapped, required this.onDragHandleTapped, super.key, }); final String initialUrl; + final bool isVisible; final VoidCallback onCloseTapped; final VoidCallback onDragHandleTapped; @@ -34,7 +34,7 @@ class _WebViewBottomSheetState extends State late final Animation _rotationAnim; static const double _minChildSize = 0.1; static const double _maxChildSize = 0.94; - bool _isLoading = true; + bool _isLoading = false; bool _canGoBack = false; bool _canGoForward = false; double _loadingProgress = 0; @@ -42,11 +42,13 @@ class _WebViewBottomSheetState extends State @override void initState() { super.initState(); + _urlController.text = widget.initialUrl; _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setNavigationDelegate( NavigationDelegate( onPageStarted: (String url) { + if (!mounted) return; setState(() { _isLoading = true; _loadingProgress = 0; @@ -54,11 +56,13 @@ class _WebViewBottomSheetState extends State }); }, onProgress: (int progress) { + if (!mounted) return; setState(() => _loadingProgress = progress / 100.0); }, onPageFinished: (String url) async { final bool canBack = await _controller.canGoBack(); final bool canFwd = await _controller.canGoForward(); + if (!mounted) return; setState(() { _isLoading = false; _loadingProgress = 1.0; @@ -68,6 +72,7 @@ class _WebViewBottomSheetState extends State }); }, onWebResourceError: (WebResourceError error) { + if (!mounted) return; setState(() => _isLoading = false); }, ), @@ -81,6 +86,7 @@ class _WebViewBottomSheetState extends State _rotationAnim = Tween(begin: 0, end: 0.5).animate(_animController); _sheetController.addListener(() { final double newSize = _sheetController.size; + final double scrollPosition = ((newSize - _minChildSize) / (_maxChildSize - _minChildSize)).clamp( 0.0, @@ -91,8 +97,25 @@ class _WebViewBottomSheetState extends State }); } + @override + void didUpdateWidget(WebViewBottomSheet oldWidget) { + super.didUpdateWidget(oldWidget); + + /// When the sheet is revealed again after being hidden, reload the + /// original page (it was navigated to about:blank when closed to stop + /// any media playback). + if (!oldWidget.isVisible && widget.isVisible) { + _controller.loadRequest(Uri.parse(widget.initialUrl)); + } + } + @override void dispose() { + /// Navigate the web view away from the loaded page so any audio/video it + /// is playing stops when the sheet is torn down (e.g. leaving the item + /// screen). WKWebView keeps playing media otherwise. + _controller.loadRequest(Uri.parse('about:blank')); + _animController.dispose(); _urlController.dispose(); _sheetController.dispose(); super.dispose(); @@ -106,13 +129,6 @@ class _WebViewBottomSheetState extends State @override Widget build(BuildContext context) { - final bool isWebViewBottomSheetEnabled = context - .select( - (PreferenceCubit cubit) => cubit.state.isWebViewBottomSheetEnabled, - ); - if (!isWebViewBottomSheetEnabled) { - return const SizedBox.shrink(); - } return DraggableScrollableSheet( controller: _sheetController, snapAnimationDuration: AppDurations.ms200, @@ -184,6 +200,9 @@ class _WebViewBottomSheetState extends State onClose: () { if (_sheetController.isAttached) { if (_sheetController.size == _minChildSize) { + _controller.loadRequest( + Uri.parse('about:blank'), + ); widget.onCloseTapped(); } else { _sheetController.animateTo(