mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-13 21:38:58 +02:00
fix(ui): improve adaptive phone and tablet layouts
This commit is contained in:
+8
-7
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user