From 958b79ebcaec2accaa35323150271b867240d390 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:44:43 +0700 Subject: [PATCH] fix(ui): improve adaptive phone and tablet layouts --- lib/app.dart | 15 +-- lib/screens/album_screen.dart | 22 ++-- lib/screens/artist_screen.dart | 1 + lib/screens/artist_screen_widgets.dart | 29 ++--- lib/screens/home_tab.dart | 7 +- lib/screens/home_tab_explore.dart | 32 +++-- lib/screens/home_tab_widgets.dart | 27 +++-- lib/screens/main_shell.dart | 12 +- .../repo/extension_details_screen.dart | 13 ++- lib/screens/track_metadata_screen.dart | 24 ++-- lib/utils/adaptive_layout.dart | 110 ++++++++++++++++-- lib/widgets/album_detail_header.dart | 39 ++++--- lib/widgets/app_sliver_header.dart | 28 +++-- lib/widgets/download_service_picker.dart | 22 ++-- test/adaptive_layout_test.dart | 87 ++++++++++++++ test/design_system_test.dart | 32 +++++ 16 files changed, 378 insertions(+), 122 deletions(-) create mode 100644 test/adaptive_layout_test.dart diff --git a/lib/app.dart b/lib/app.dart index 130461a7..08cc3569 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/services/app_navigation_service.dart'; import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/l10n/supported_locales.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; String initialLocationForAppState({ required bool isFirstLaunch, @@ -117,9 +118,6 @@ class _OrientationFadeState extends State<_OrientationFade> duration: const Duration(milliseconds: 300), value: 1, ); - // Matches the shell's NavigationRail breakpoint. - static const double _railBreakpoint = 600; - Orientation? _lastOrientation; double? _lastWidth; @@ -132,7 +130,8 @@ class _OrientationFadeState extends State<_OrientationFade> _lastOrientation != null && orientation != _lastOrientation; final crossedRailBreakpoint = _lastWidth != null && - (_lastWidth! < _railBreakpoint) != (width < _railBreakpoint); + useNavigationRailForWidth(_lastWidth!) != + useNavigationRailForWidth(width); if (orientationChanged || crossedRailBreakpoint) { _controller.forward(from: 0); } @@ -209,9 +208,11 @@ class SpotiFLACApp extends ConsumerWidget { // level it sits above the router's Navigator, so the controller // never encounters it while collecting heroes. Removing the // inherited controller disables flights on the root Navigator. - child: heroAnimationsEnabled - ? appContent - : HeroControllerScope.none(child: appContent), + child: AdaptiveUiScaler( + child: heroAnimationsEnabled + ? appContent + : HeroControllerScope.none(child: appContent), + ), ); }, routerConfig: router, diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 8eb9575a..46ade16c 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -514,25 +514,19 @@ class _AlbumScreenState extends ConsumerState ? context.l10n.selectionSelected(selectedIds.length) : null, leading: isSelectionMode - ? Padding( - padding: const EdgeInsets.only(left: 8), - child: HeaderCircleButton( - icon: Icons.close, - tooltip: MaterialLocalizations.of(context).closeButtonTooltip, - onPressed: exitSelectionMode, - ), + ? HeaderCircleButton( + icon: Icons.close, + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: exitSelectionMode, ) : null, appBarActions: isSelectionMode ? const [] : [ - Padding( - padding: const EdgeInsets.only(right: 8), - child: HeaderCircleButton( - icon: Icons.open_in_new_rounded, - tooltip: context.l10n.openInOtherServices, - onPressed: () => _showShareSheet(context, tracks, artistName), - ), + HeaderCircleButton( + icon: Icons.open_in_new_rounded, + tooltip: context.l10n.openInOtherServices, + onPressed: () => _showShareSheet(context, tracks, artistName), ), ], ); diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index dd14e4a3..205da292 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -16,6 +16,7 @@ import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/provider_resource_ids.dart'; import 'package:spotiflac_android/utils/ttl_cache.dart'; diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index d9d72f0f..aab2a865 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -81,6 +81,7 @@ extension _ArtistScreenSections on _ArtistScreenState { required String? listenersText, required bool isFavoriteArtist, }) { + final edgeInset = detailHeaderEdgeInset(context); return SliverAppBar( expandedHeight: hasDiscography ? 420 : 380, pinned: true, @@ -186,8 +187,8 @@ extension _ArtistScreenSections on _ArtistScreenState { ), ), Positioned( - left: 16, - right: 16, + left: 16 + edgeInset, + right: 16 + edgeInset, bottom: 16, child: Row( crossAxisAlignment: CrossAxisAlignment.end, @@ -274,19 +275,21 @@ extension _ArtistScreenSections on _ArtistScreenState { ), stretchModes: const [StretchMode.zoomBackground], ), - leading: HeaderCircleButton( - icon: Icons.arrow_back, - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - onPressed: () => Navigator.pop(context), + leadingWidth: kToolbarHeight + edgeInset, + leading: Padding( + padding: EdgeInsets.only(left: edgeInset), + child: HeaderCircleButton( + icon: Icons.arrow_back, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => Navigator.pop(context), + ), ), + actionsPadding: EdgeInsets.only(right: edgeInset), actions: [ - Padding( - padding: const EdgeInsets.only(right: 8), - child: HeaderCircleButton( - icon: Icons.open_in_new_rounded, - tooltip: context.l10n.openInOtherServices, - onPressed: () => _showShareSheet(context), - ), + HeaderCircleButton( + icon: Icons.open_in_new_rounded, + tooltip: context.l10n.openInOtherServices, + onPressed: () => _showShareSheet(context), ), ], ); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 62b2a269..d4255538 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -122,7 +122,7 @@ class _HomeTabState extends ConsumerState } double _recentDownloadCoverSize(BuildContext context) { - final scale = _responsiveScale(context: context, min: 0.82, max: 1.05); + final scale = _responsiveScale(context: context, min: 0.82, max: 1.25); final textScale = _effectiveTextScale(context); return 100 * scale * (1 + (textScale - 1) * 0.15); } @@ -134,7 +134,10 @@ class _HomeTabState extends ConsumerState } double _exploreCardSize(BuildContext context) { - final scale = _responsiveScale(context: context, min: 0.82, max: 1.08); + // Six phone-sized cards across an iPad made the feed look like a desktop + // page zoomed out. A higher tablet cap gives roughly five generous cards + // in portrait while leaving all phone sizes unchanged. + final scale = _responsiveScale(context: context, min: 0.82, max: 1.32); final textScale = _effectiveTextScale(context); return 145 * scale * (1 + (textScale - 1) * 0.12); } diff --git a/lib/screens/home_tab_explore.dart b/lib/screens/home_tab_explore.dart index 8e23d309..538a1902 100644 --- a/lib/screens/home_tab_explore.dart +++ b/lib/screens/home_tab_explore.dart @@ -7,6 +7,7 @@ extension _HomeTabExploreUI on _HomeTabState { ColorScheme colorScheme, ) { final hasGreeting = greeting != null && greeting.isNotEmpty; + final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600; final sectionOffset = hasGreeting ? 1 : 0; final totalCount = sections.length + sectionOffset + 1; @@ -18,9 +19,11 @@ extension _HomeTabExploreUI on _HomeTabState { padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Text( greeting, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: + (isTablet + ? Theme.of(context).textTheme.headlineMedium + : Theme.of(context).textTheme.headlineSmall) + ?.copyWith(fontWeight: FontWeight.bold), ), ); } @@ -42,6 +45,7 @@ extension _HomeTabExploreUI on _HomeTabState { Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) { final sectionHeight = _exploreSectionHeight(context); + final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600; if (section.isYTMusicQuickPicks) { return _buildYTMusicQuickPicksSection(section, colorScheme); } @@ -53,9 +57,10 @@ extension _HomeTabExploreUI on _HomeTabState { padding: const EdgeInsets.fromLTRB(16, 20, 16, 12), child: Text( section.title, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontSize: isTablet ? 20 : null, + fontWeight: FontWeight.bold, + ), ), ), SizedBox( @@ -157,6 +162,7 @@ extension _HomeTabExploreUI on _HomeTabState { Widget _buildExploreItem(ExploreItem item, ColorScheme colorScheme) { final isArtist = item.type == 'artist'; + final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600; final cardSize = _exploreCardSize(context); final iconSize = cardSize * 0.3; @@ -225,10 +231,14 @@ extension _HomeTabExploreUI on _HomeTabState { maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: isArtist ? TextAlign.center : TextAlign.start, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), + style: + (isTablet + ? Theme.of(context).textTheme.titleMedium + : Theme.of(context).textTheme.bodyMedium) + ?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), ), if (item.artists.isNotEmpty && !isArtist) ClickableArtistName( @@ -239,7 +249,7 @@ extension _HomeTabExploreUI on _HomeTabState { overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, - fontSize: 12, + fontSize: isTablet ? 14 : 12, ), ), ], diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 9e4113c2..27febbbc 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -782,6 +782,7 @@ class _ArtistLoadingScaffold extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; + final edgeInset = detailHeaderEdgeInset(context); final url = coverUrl; final hasImage = url != null && @@ -836,8 +837,8 @@ class _ArtistLoadingScaffold extends StatelessWidget { ), ), Positioned( - left: 16, - right: 16, + left: 16 + edgeInset, + right: 16 + edgeInset, bottom: 16, child: Text( artistName, @@ -860,17 +861,21 @@ class _ArtistLoadingScaffold extends StatelessWidget { ], ), ), - leading: IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.4), - shape: BoxShape.circle, + leadingWidth: kToolbarHeight + edgeInset, + leading: Padding( + padding: EdgeInsets.only(left: edgeInset), + child: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon(Icons.arrow_back, color: Colors.white), ), - child: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), ), - onPressed: () => Navigator.pop(context), ), ), const SliverToBoxAdapter( diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 07152c73..ea307603 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -31,6 +31,7 @@ import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/mini_player.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; final _log = AppLogger('MainShell'); @@ -746,9 +747,12 @@ class _MainShellState extends ConsumerState }); } - // Material breakpoint: rail navigation on tablet/landscape widths, the - // bottom NavigationBar on phones. - final useNavigationRail = MediaQuery.sizeOf(context).width >= 600; + final screenSize = MediaQuery.sizeOf(context); + final isTablet = screenSize.shortestSide >= 600; + // Tablets remain touch-first in both orientations. Reserving the rail for + // desktop-width windows keeps all primary destinations at the reachable + // bottom edge on iPad and large Android tablets. + final useNavigationRail = useNavigationRailForWidth(screenSize.width); final pageView = KeyedSubtree( key: _pageViewKey, @@ -863,7 +867,7 @@ class _MainShellState extends ConsumerState onDestinationSelected: _onNavTap, animationDuration: const Duration(milliseconds: 500), elevation: 0, - height: 64, + height: isTablet ? 72 : 64, backgroundColor: settingsGroupColor( context, ).withValues(alpha: 0.72), diff --git a/lib/screens/repo/extension_details_screen.dart b/lib/screens/repo/extension_details_screen.dart index b40c0106..f947a80c 100644 --- a/lib/screens/repo/extension_details_screen.dart +++ b/lib/screens/repo/extension_details_screen.dart @@ -85,6 +85,7 @@ class _ExtensionDetailsScreenState RepoExtension ext, ColorScheme colorScheme, ) { + final edgeInset = detailHeaderEdgeInset(context); return SliverAppBar( expandedHeight: 200, pinned: true, @@ -124,10 +125,14 @@ class _ExtensionDetailsScreenState ), ), ), - leading: IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), + leadingWidth: kToolbarHeight + edgeInset, + leading: Padding( + padding: EdgeInsets.only(left: edgeInset), + child: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), ), ); } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index d8625fe8..1c65d21f 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -644,6 +644,7 @@ class _TrackMetadataScreenState extends ConsumerState final colorScheme = Theme.of(context).colorScheme; final expandedHeight = calculateExpandedHeight(context); final bottomInset = context.navBarBottomInset; + final edgeInset = detailHeaderEdgeInset(context); return GestureDetector( behavior: HitTestBehavior.translucent, @@ -692,18 +693,23 @@ class _TrackMetadataScreenState extends ConsumerState ); }, ), - leading: IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.4), - shape: BoxShape.circle, + leadingWidth: kToolbarHeight + edgeInset, + leading: Padding( + padding: EdgeInsets.only(left: edgeInset), + child: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon(Icons.arrow_back, color: Colors.white), ), - child: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: _popWithMetadataResult, ), - onPressed: _popWithMetadataResult, ), + actionsPadding: EdgeInsets.only(right: edgeInset), actions: [ IconButton( tooltip: MaterialLocalizations.of(context).showMenuTooltip, diff --git a/lib/utils/adaptive_layout.dart b/lib/utils/adaptive_layout.dart index 69b5a7c6..22f5ed57 100644 --- a/lib/utils/adaptive_layout.dart +++ b/lib/utils/adaptive_layout.dart @@ -1,26 +1,112 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; + +/// Keeps touch-first tablets on the bottom navigation used by phones. A rail +/// is reserved for genuinely desktop-width windows; the old 600dp threshold +/// made an iPad portrait layout look like a shrunken desktop app. +const double navigationRailBreakpoint = 1440; +const double defaultWideContentMaxWidth = 960; +const double maxWideContentInset = 32; + +bool useNavigationRailForWidth(double width) => + width >= navigationRailBreakpoint; + +/// Explicit horizontal safe spacing for controls drawn in full-bleed detail +/// headers. iOS reports no left/right safe-area inset in portrait, so toolbar +/// buttons otherwise land only a few pixels from the device edge. +double detailHeaderEdgeInset(BuildContext context) { + final platform = Theme.of(context).platform; + return platform == TargetPlatform.iOS || platform == TargetPlatform.macOS + ? 12 + : 0; +} + +/// Visual scale for touch-first tablet surfaces. Large iPads have enough +/// pixels to make an unscaled phone density feel miniature, especially when +/// Simulator shrinks the whole device to fit a desktop display. +double adaptiveUiScaleForSize(Size size) { + if (size.shortestSide < 600 || size.width >= navigationRailBreakpoint) { + return 1; + } + return size.shortestSide >= 800 ? 1.2 : 1.1; +} + +EdgeInsets _scaledInsets(EdgeInsets value, double divisor) => EdgeInsets.only( + left: value.left / divisor, + top: value.top / divisor, + right: value.right / divisor, + bottom: value.bottom / divisor, +); + +/// Scales the complete app surface on tablets, including controls, dialogs, +/// navigation, spacing and hit targets. The child receives the correspondingly +/// smaller logical viewport so responsive layouts still reflow instead of +/// being cropped, while [FittedBox] maps that viewport back to the full screen. +class AdaptiveUiScaler extends StatelessWidget { + const AdaptiveUiScaler({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + final scale = adaptiveUiScaleForSize(mediaQuery.size); + if (scale == 1) return child; + + final scaledSize = Size( + mediaQuery.size.width / scale, + mediaQuery.size.height / scale, + ); + final scaledMediaQuery = mediaQuery.copyWith( + size: scaledSize, + // One inner logical pixel occupies [scale] outer logical pixels. + // Advertising the effective density keeps decoded artwork sharp. + devicePixelRatio: mediaQuery.devicePixelRatio * scale, + padding: _scaledInsets(mediaQuery.padding, scale), + viewPadding: _scaledInsets(mediaQuery.viewPadding, scale), + viewInsets: _scaledInsets(mediaQuery.viewInsets, scale), + systemGestureInsets: _scaledInsets(mediaQuery.systemGestureInsets, scale), + ); + + return SizedBox.expand( + child: FittedBox( + fit: BoxFit.fill, + alignment: Alignment.topLeft, + child: SizedBox.fromSize( + size: scaledSize, + child: MediaQuery(data: scaledMediaQuery, child: child), + ), + ), + ); + } +} /// Widest content span for a surface of [maxWidth]: content is never narrower -/// than [contentMaxWidth], and the centering margin never exceeds 80dp per +/// than [contentMaxWidth], and the centering margin never exceeds 32dp per /// side so tablets retain near-full-width rows. double adaptiveContentMaxWidth( double maxWidth, { - double contentMaxWidth = 720, -}) => maxWidth > contentMaxWidth + 160 ? maxWidth - 160 : contentMaxWidth; + double contentMaxWidth = defaultWideContentMaxWidth, +}) => maxWidth > contentMaxWidth + (maxWideContentInset * 2) + ? maxWidth - (maxWideContentInset * 2) + : contentMaxWidth; /// Horizontal inset that centers content of [maxWidth] at /// [adaptiveContentMaxWidth]; zero once it fits. Prefer this constraint-based /// form when the widget may live inside an already clamped box (e.g. a bottom /// sheet), where screen width would over-inset. -double wideInsetForWidth(double maxWidth, {double contentMaxWidth = 720}) => - maxWidth > contentMaxWidth - ? ((maxWidth - contentMaxWidth) / 2).clamp(0.0, 80.0) +double wideInsetForWidth( + double maxWidth, { + double contentMaxWidth = defaultWideContentMaxWidth, +}) => maxWidth > contentMaxWidth + ? ((maxWidth - contentMaxWidth) / 2).clamp(0.0, maxWideContentInset) : 0; /// Horizontal inset that centers full-width list content on tablets/landscape; /// zero on phones, so rows stop stretching across the whole screen. -double wideListInset(BuildContext context, {double contentMaxWidth = 720}) => - wideInsetForWidth( - MediaQuery.sizeOf(context).width, - contentMaxWidth: contentMaxWidth, - ); +double wideListInset( + BuildContext context, { + double contentMaxWidth = defaultWideContentMaxWidth, +}) => wideInsetForWidth( + MediaQuery.sizeOf(context).width, + contentMaxWidth: contentMaxWidth, +); diff --git a/lib/widgets/album_detail_header.dart b/lib/widgets/album_detail_header.dart index 3e7cc7db..7fbc1804 100644 --- a/lib/widgets/album_detail_header.dart +++ b/lib/widgets/album_detail_header.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; /// Collapsing album-detail header shared by the album, local-album, and /// downloaded-album screens: full-bleed [background] (optionally blurred and @@ -91,6 +92,11 @@ class AlbumDetailHeader extends StatelessWidget { Widget _buildAppBar(BuildContext context, ColorScheme headerScheme) { final tokens = context.tokens; + // iOS does not add horizontal safe-area padding in portrait. Give toolbar + // controls and header content an explicit inset so circular actions do not + // sit against the glass edge on either iPhone or iPad. Android retains its + // existing spacing. + final iosEdgeInset = detailHeaderEdgeInset(context); // Scrim and gradient are drawn from the palette surface instead of black, // so a light theme gets a light header with dark text and a dark theme // keeps the familiar dark treatment — both tinted by the artwork. @@ -157,8 +163,8 @@ class AlbumDetailHeader extends StatelessWidget { ), ), Positioned( - left: 20, - right: 20, + left: 20 + iosEdgeInset, + right: 20 + iosEdgeInset, bottom: 40, child: AnimatedOpacity( duration: tokens.motionFast, @@ -235,20 +241,25 @@ class AlbumDetailHeader extends StatelessWidget { ); }, ), - leading: - leading ?? - IconButton.filledTonal( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const Icon(Icons.arrow_back), - style: IconButton.styleFrom( - minimumSize: Size.square(tokens.minTouchTarget), - backgroundColor: headerScheme.surfaceContainerHigh.withValues( - alpha: 0.75, + leadingWidth: kToolbarHeight + iosEdgeInset, + leading: Padding( + padding: EdgeInsets.only(left: iosEdgeInset), + child: + leading ?? + IconButton.filledTonal( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: const Icon(Icons.arrow_back), + style: IconButton.styleFrom( + minimumSize: Size.square(tokens.minTouchTarget), + backgroundColor: headerScheme.surfaceContainerHigh.withValues( + alpha: 0.75, + ), + foregroundColor: headerScheme.onSurfaceVariant, ), - foregroundColor: headerScheme.onSurfaceVariant, + onPressed: () => Navigator.pop(context), ), - onPressed: () => Navigator.pop(context), - ), + ), + actionsPadding: EdgeInsets.only(right: iosEdgeInset), actions: appBarActions, ); } diff --git a/lib/widgets/app_sliver_header.dart b/lib/widgets/app_sliver_header.dart index 43e61de7..1e4ace05 100644 --- a/lib/widgets/app_sliver_header.dart +++ b/lib/widgets/app_sliver_header.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; /// The collapsing header used by every top-level tab and every settings-style @@ -44,6 +45,7 @@ class AppSliverHeader extends StatelessWidget { final topPadding = normalizedHeaderTopPadding(context); final maxHeight = tokens.headerExpandedHeight + topPadding; final minHeight = kToolbarHeight + topPadding; + final edgeInset = detailHeaderEdgeInset(context); return SliverAppBar( expandedHeight: maxHeight, @@ -53,14 +55,22 @@ class AppSliverHeader extends StatelessWidget { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, automaticallyImplyLeading: false, + leadingWidth: _showLeading ? kToolbarHeight + edgeInset : null, leading: _showLeading - ? leading ?? - IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ) + ? Padding( + padding: EdgeInsets.only(left: edgeInset), + child: + leading ?? + IconButton( + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + ) : null, + actionsPadding: EdgeInsets.only(right: edgeInset), actions: actions, flexibleSpace: LayoutBuilder( builder: (context, constraints) { @@ -68,8 +78,9 @@ class AppSliverHeader extends StatelessWidget { ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)) .clamp(0.0, 1.0); final leftPadding = _showLeading - ? _leadingClearance - - ((_leadingClearance - _contentMargin) * expandRatio) + ? (_leadingClearance + edgeInset) - + (((_leadingClearance + edgeInset) - _contentMargin) * + expandRatio) : _contentMargin; final fontSize = tokens.headerCollapsedTitleSize + @@ -78,6 +89,7 @@ class AppSliverHeader extends StatelessWidget { expandRatio; return FlexibleSpaceBar( + centerTitle: false, expandedTitleScale: 1.0, titlePadding: EdgeInsets.only( left: leftPadding, diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 6caaf705..bbc96963 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -13,7 +13,6 @@ class DownloadServicePicker extends ConsumerStatefulWidget { final String? coverUrl; final void Function(String quality, String service) onSelect; final String? recommendedService; - final ScrollController? scrollController; const DownloadServicePicker({ super.key, @@ -22,7 +21,6 @@ class DownloadServicePicker extends ConsumerStatefulWidget { this.coverUrl, required this.onSelect, this.recommendedService, - this.scrollController, }); @override @@ -44,16 +42,15 @@ class DownloadServicePicker extends ConsumerStatefulWidget { useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, isScrollControlled: true, - enableDrag: false, - builder: (context) => AppDraggableSheet( - builder: (context, scrollController) => DownloadServicePicker( - trackName: trackName, - artistName: artistName, - coverUrl: coverUrl, - onSelect: onSelect, - recommendedService: recommendedService, - scrollController: scrollController, - ), + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.88, + ), + builder: (context) => DownloadServicePicker( + trackName: trackName, + artistName: artistName, + coverUrl: coverUrl, + onSelect: onSelect, + recommendedService: recommendedService, ), ); } @@ -126,7 +123,6 @@ class _DownloadServicePickerState extends ConsumerState { return SafeArea( child: SingleChildScrollView( - controller: widget.scrollController, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/test/adaptive_layout_test.dart b/test/adaptive_layout_test.dart new file mode 100644 index 00000000..dc9a6ef5 --- /dev/null +++ b/test/adaptive_layout_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; + +void main() { + group('adaptive navigation', () { + test('keeps phone and tablet widths on reachable bottom navigation', () { + expect(useNavigationRailForWidth(390), isFalse); + expect(useNavigationRailForWidth(1032), isFalse); + expect(useNavigationRailForWidth(1366), isFalse); + }); + + test('reserves the navigation rail for desktop-width windows', () { + expect(useNavigationRailForWidth(navigationRailBreakpoint - 1), isFalse); + expect(useNavigationRailForWidth(navigationRailBreakpoint), isTrue); + }); + }); + + group('wide content', () { + test('uses the full tablet width before applying a small gutter', () { + expect(wideInsetForWidth(853), 0); + expect(wideInsetForWidth(1024), 32); + expect(wideInsetForWidth(1366), maxWideContentInset); + }); + + test('never removes more than the shared gutter from each side', () { + expect(adaptiveContentMaxWidth(1366), 1302); + }); + }); + + group('adaptive UI scale', () { + test('keeps phone density unchanged', () { + expect(adaptiveUiScaleForSize(const Size(430, 932)), 1); + }); + + test('enlarges compact and large tablet touch surfaces', () { + expect(adaptiveUiScaleForSize(const Size(700, 1000)), 1.1); + expect(adaptiveUiScaleForSize(const Size(1024, 1366)), 1.2); + expect(adaptiveUiScaleForSize(const Size(1366, 1024)), 1.2); + }); + + test('keeps desktop-width windows at native density', () { + expect(adaptiveUiScaleForSize(const Size(1440, 900)), 1); + }); + + testWidgets('reflows the tablet viewport and preserves touch mapping', ( + tester, + ) async { + tester.view.physicalSize = const Size(2048, 2732); + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + Size? innerSize; + var taps = 0; + await tester.pumpWidget( + MediaQuery( + data: MediaQueryData.fromView(tester.view), + child: Directionality( + textDirection: TextDirection.ltr, + child: AdaptiveUiScaler( + child: Builder( + builder: (context) { + innerSize = MediaQuery.sizeOf(context); + return Align( + alignment: Alignment.topLeft, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => taps++, + child: const SizedBox.square(dimension: 100), + ), + ); + }, + ), + ), + ), + ), + ); + + expect(innerSize!.width, closeTo(1024 / 1.2, 0.01)); + expect(innerSize!.height, closeTo(1366 / 1.2, 0.01)); + await tester.tapAt(const Offset(110, 110)); + await tester.pump(); + expect(taps, 1); + }); + }); +} diff --git a/test/design_system_test.dart b/test/design_system_test.dart index 4cbc0792..56f51bf6 100644 --- a/test/design_system_test.dart +++ b/test/design_system_test.dart @@ -12,6 +12,7 @@ import 'package:spotiflac_android/theme/app_theme.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/collection_scaffold.dart'; @@ -254,6 +255,37 @@ void main() { }); }); + group('AlbumDetailHeader', () { + testWidgets('keeps iOS toolbar controls clear of the screen edge', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light().copyWith(platform: TargetPlatform.iOS), + home: Scaffold( + body: CustomScrollView( + slivers: const [ + AlbumDetailHeader( + title: 'Album', + expandedHeight: 500, + showTitleInAppBar: false, + background: ColoredBox(color: Colors.orange), + appBarActions: [SizedBox.square(dimension: 48)], + ), + ], + ), + ), + ), + ); + + final appBar = tester.widget(find.byType(SliverAppBar)); + expect(appBar.leadingWidth, kToolbarHeight + 12); + expect(appBar.actionsPadding, const EdgeInsets.only(right: 12)); + final leadingPadding = appBar.leading! as Padding; + expect(leadingPadding.padding, const EdgeInsets.only(left: 12)); + }); + }); + group('AppSliverHeader', () { testWidgets('tab root variant shows the title without a back button', ( tester,