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:
+122
-127
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user