From 489bf914afae5505f92e68afd9de51ffc7d03821 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:20:02 +0200 Subject: [PATCH] refactor(back): host-owned sheet back in hub + media detail hub_detail and media_detail (TV + non-TV) drop their hand-rolled PopScopes and pass canPop/onSystemBack to OverlaySheetHost. Keeps appbar-focus-first and Android-keyboard block behavior; fixes system-back-with-sheet-open leaking to a screen pop. iOS swipe-back preserved. --- lib/screens/hub_detail_screen.dart | 249 +++++++++++++-------------- lib/screens/media_detail_screen.dart | 30 ++-- 2 files changed, 133 insertions(+), 146 deletions(-) diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 1202dc31..0be7e583 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -477,153 +477,148 @@ class _HubDetailScreenState extends State @override Widget build(BuildContext context) { - return PopScope( - canPop: PlatformDetector.isHandheldIOS(context), - onPopInvokedWithResult: (didPop, _) { - if (BackKeyCoordinator.consumeIfHandled()) return; - if (didPop) return; - final shouldPop = handleBackNavigation(); - if (shouldPop && mounted) { - Navigator.pop(context); - } - }, - child: PrimaryScrollController( + return PrimaryScrollController( + controller: scrollController, + child: IosStatusBarTapScrollToTop( controller: scrollController, - child: IosStatusBarTapScrollToTop( - controller: scrollController, - child: OverlaySheetHost( - child: Scaffold( - key: _overlayChildKey, - body: CustomScrollView( - primary: true, - clipBehavior: Clip.none, - slivers: [ - CustomAppBar(title: Text(widget.hub.title), pinned: true, actions: buildFocusableAppBarActions()), - if (_errorMessage != null) - SliverErrorState(message: _errorMessage!, onRetry: _loadMoreItems) - else if (_filteredItems.isEmpty && _isLoading) - LoadingIndicatorBox.sliver - else if (_filteredItems.isEmpty) - SliverFillRemaining(child: Center(child: Text(t.hubDetail.noItemsFound))) - else - SettingsBuilder( - prefs: const [ - SettingsService.viewMode, - SettingsService.episodePosterMode, - SettingsService.libraryDensity, - SettingsService.tvFullCardLayout, - ], - builder: (context) { - final svc = SettingsService.instance; - final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; - final episodePosterMode = svc.read(SettingsService.episodePosterMode); - final libraryDensity = svc.read(SettingsService.libraryDensity); - final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout); + child: OverlaySheetHost( + // Host owns sheet + system back: a back with a sheet open closes it; + // otherwise focus the app bar first, then pop (handleBackNavigation). + // canPop preserves the iOS interactive swipe-back. + canPop: PlatformDetector.isHandheldIOS(context), + onSystemBack: () { + if (BackKeyCoordinator.consumeIfHandled()) return; + if (handleBackNavigation() && mounted) Navigator.pop(context); + }, + child: Scaffold( + key: _overlayChildKey, + body: CustomScrollView( + primary: true, + clipBehavior: Clip.none, + slivers: [ + CustomAppBar(title: Text(widget.hub.title), pinned: true, actions: buildFocusableAppBarActions()), + if (_errorMessage != null) + SliverErrorState(message: _errorMessage!, onRetry: _loadMoreItems) + else if (_filteredItems.isEmpty && _isLoading) + LoadingIndicatorBox.sliver + else if (_filteredItems.isEmpty) + SliverFillRemaining(child: Center(child: Text(t.hubDetail.noItemsFound))) + else + SettingsBuilder( + prefs: const [ + SettingsService.viewMode, + SettingsService.episodePosterMode, + SettingsService.libraryDensity, + SettingsService.tvFullCardLayout, + ], + builder: (context) { + final svc = SettingsService.instance; + final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; + final episodePosterMode = svc.read(SettingsService.episodePosterMode); + final libraryDensity = svc.read(SettingsService.libraryDensity); + final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout); - // Determine hub content type for layout decisions - final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode)); - final hasNonEpisodes = _filteredItems.any( - (item) => !item.usesWideAspectRatio(episodePosterMode), + // Determine hub content type for layout decisions + final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode)); + final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode)); + + // Mixed hub = has both episodes AND non-episodes + final isMixedHub = hasEpisodes && hasNonEpisodes; + + // Episode-only = all items are episodes with thumbnails + final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; + + // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) + final useWideLayout = + episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); + + if (isListMode) { + return SliverPadding( + padding: const EdgeInsets.all(8), + sliver: SliverList.builder( + itemCount: _filteredItems.length, + itemBuilder: (context, index) { + final item = _filteredItems[index]; + final focusNode = _focusNodeForIndex(index); + + return FocusableMediaCard( + focusNode: focusNode, + item: item, + disableScale: true, + onRefresh: _handleItemRefresh, + onRemoveFromContinueWatching: widget.isInContinueWatching + ? _handleRemoveFromContinueWatching + : null, + isInContinueWatching: widget.isInContinueWatching, + onNavigateUp: index == 0 ? navigateToAppBar : null, + onBack: handleBackFromContent, + onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), + mixedHubContext: isMixedHub, + ); + }, + ), ); + } - // Mixed hub = has both episodes AND non-episodes - final isMixedHub = hasEpisodes && hasNonEpisodes; + return SliverPadding( + padding: const EdgeInsets.all(8), + sliver: SliverLayoutBuilder( + builder: (context, constraints) { + final maxExtent = GridSizeCalculator.getMaxCrossAxisExtentWithPadding( + context, + libraryDensity, + 16, + ); + final gridSpacing = MediaGridDelegate.spacingFor( + context: context, + fullBleedImage: fullCardLayout, + ); + final columnCount = GridSizeCalculator.getColumnCount( + constraints.crossAxisExtent, + useWideLayout ? maxExtent * 1.8 : maxExtent, + crossAxisSpacing: gridSpacing, + ); - // Episode-only = all items are episodes with thumbnails - final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; - - // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) - final useWideLayout = - episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); - - if (isListMode) { - return SliverPadding( - padding: const EdgeInsets.all(8), - sliver: SliverList.builder( - itemCount: _filteredItems.length, - itemBuilder: (context, index) { + return SliverGrid( + gridDelegate: MediaGridDelegate.createDelegate( + context: context, + density: libraryDensity, + usePaddingAware: true, + horizontalPadding: 16, + useWideAspectRatio: useWideLayout, + fullBleedImage: fullCardLayout, + ), + delegate: SliverChildBuilderDelegate((context, index) { final item = _filteredItems[index]; final focusNode = _focusNodeForIndex(index); + final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount); + final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount); return FocusableMediaCard( focusNode: focusNode, item: item, - disableScale: true, onRefresh: _handleItemRefresh, onRemoveFromContinueWatching: widget.isInContinueWatching ? _handleRemoveFromContinueWatching : null, isInContinueWatching: widget.isInContinueWatching, - onNavigateUp: index == 0 ? navigateToAppBar : null, + onNavigateUp: isFirstRow ? navigateToAppBar : null, + onNavigateLeft: isFirstColumn ? () {} : null, onBack: handleBackFromContent, onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), mixedHubContext: isMixedHub, - ); - }, - ), - ); - } - - return SliverPadding( - padding: const EdgeInsets.all(8), - sliver: SliverLayoutBuilder( - builder: (context, constraints) { - final maxExtent = GridSizeCalculator.getMaxCrossAxisExtentWithPadding( - context, - libraryDensity, - 16, - ); - final gridSpacing = MediaGridDelegate.spacingFor( - context: context, - fullBleedImage: fullCardLayout, - ); - final columnCount = GridSizeCalculator.getColumnCount( - constraints.crossAxisExtent, - useWideLayout ? maxExtent * 1.8 : maxExtent, - crossAxisSpacing: gridSpacing, - ); - - return SliverGrid( - gridDelegate: MediaGridDelegate.createDelegate( - context: context, - density: libraryDensity, - usePaddingAware: true, - horizontalPadding: 16, - useWideAspectRatio: useWideLayout, fullBleedImage: fullCardLayout, - ), - delegate: SliverChildBuilderDelegate((context, index) { - final item = _filteredItems[index]; - final focusNode = _focusNodeForIndex(index); - final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount); - final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount); - - return FocusableMediaCard( - focusNode: focusNode, - item: item, - onRefresh: _handleItemRefresh, - onRemoveFromContinueWatching: widget.isInContinueWatching - ? _handleRemoveFromContinueWatching - : null, - isInContinueWatching: widget.isInContinueWatching, - onNavigateUp: isFirstRow ? navigateToAppBar : null, - onNavigateLeft: isFirstColumn ? () {} : null, - onBack: handleBackFromContent, - onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), - mixedHubContext: isMixedHub, - fullBleedImage: fullCardLayout, - ); - }, childCount: _filteredItems.length), - ); - }, - ), - ); - }, - ), - if (_filteredItems.isNotEmpty && (_isLoadingMore || _continuationErrorMessage != null)) - _buildContinuationStatusSliver(), - ], - ), + ); + }, childCount: _filteredItems.length), + ); + }, + ), + ); + }, + ), + if (_filteredItems.isNotEmpty && (_isLoadingMore || _continuationErrorMessage != null)) + _buildContinuationStatusSliver(), + ], ), ), ), diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index f2a30463..8e7fec80 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -3061,11 +3061,16 @@ class _MediaDetailScreenState extends State return _buildTvDetailScreen(context, metadata, _handleMediaDetailBackKey); } + final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context); final content = PrimaryScrollController( controller: _scrollController, child: IosStatusBarTapScrollToTop( controller: _scrollController, child: OverlaySheetHost( + // blockSystemBack keeps the route from double-popping on Android + // keyboard/TV (the key handler owns dpad back); elsewhere canPop:true + // keeps the iOS swipe-back. The host also closes an open sheet on back. + canPop: !blockSystemBack, child: Focus( onKeyEvent: _handleMediaDetailBackKey, child: Scaffold( @@ -3283,17 +3288,7 @@ class _MediaDetailScreenState extends State ), ); - final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context); - if (!blockSystemBack) { - return content; - } - - return PopScope( - canPop: false, // Prevent system back from double-popping on Android keyboard/TV - // ignore: no-empty-block - required callback, blocks system back on Android TV - onPopInvokedWithResult: (didPop, result) {}, - child: content, - ); + return content; } Widget _buildTvDetailScreen( @@ -3364,7 +3359,11 @@ class _MediaDetailScreenState extends State ], ); + final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context); final content = OverlaySheetHost( + // blockSystemBack keeps the route from double-popping on Android keyboard/ + // TV (the key handler owns dpad back); the host also closes an open sheet. + canPop: !blockSystemBack, child: Focus( onKeyEvent: handleBack, child: Scaffold( @@ -3383,14 +3382,7 @@ class _MediaDetailScreenState extends State ), ); - final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context); - if (!blockSystemBack) return content; - return PopScope( - canPop: false, - // ignore: no-empty-block - required callback, blocks system back on Android TV - onPopInvokedWithResult: (didPop, result) {}, - child: content, - ); + return content; } Widget _buildTvDetailForeground(