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.
This commit is contained in:
edde746
2026-06-07 09:20:02 +02:00
parent 248b5fd91a
commit 489bf914af
2 changed files with 133 additions and 146 deletions
+122 -127
View File
@@ -477,153 +477,148 @@ class _HubDetailScreenState extends State<HubDetailScreen>
@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(),
],
),
),
),
+11 -19
View File
@@ -3061,11 +3061,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
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<MediaDetailScreen>
),
);
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<MediaDetailScreen>
],
);
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<MediaDetailScreen>
),
);
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(