fix(ui): improve adaptive phone and tablet layouts

This commit is contained in:
zarzet
2026-09-04 23:44:43 +07:00
parent 514081fc6a
commit 958b79ebca
16 changed files with 378 additions and 122 deletions
+8 -7
View File
@@ -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,
+8 -14
View File
@@ -514,25 +514,19 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
? 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),
),
],
);
+1
View File
@@ -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';
+16 -13
View File
@@ -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),
),
],
);
+5 -2
View File
@@ -122,7 +122,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
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<HomeTab>
}
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);
}
+21 -11
View File
@@ -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,
),
),
],
+16 -11
View File
@@ -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(
+8 -4
View File
@@ -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<MainShell>
});
}
// 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<MainShell>
onDestinationSelected: _onNavTap,
animationDuration: const Duration(milliseconds: 500),
elevation: 0,
height: 64,
height: isTablet ? 72 : 64,
backgroundColor: settingsGroupColor(
context,
).withValues(alpha: 0.72),
@@ -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),
),
),
);
}
+15 -9
View File
@@ -644,6 +644,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
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<TrackMetadataScreen>
);
},
),
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,
+98 -12
View File
@@ -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,
);
+25 -14
View File
@@ -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,
);
}
+20 -8
View File
@@ -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,
+9 -13
View File
@@ -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<DownloadServicePicker> {
return SafeArea(
child: SingleChildScrollView(
controller: widget.scrollController,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
+87
View File
@@ -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);
});
});
}
+32
View File
@@ -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<SliverAppBar>(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,