From c8338cce93944d07c405f4dbc0092fb23a3b8d5f Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Fri, 6 Nov 2026 18:08:29 +0800 Subject: [PATCH 01/11] Cache card face and key Stack children to preserve artwork state Move the song key from SongCard to its Stack parent and retain the built card face across orbit ticks, so surviving cards keep their State and decoded artwork instead of being rebuilt each frame. --- lib/features/songs/widgets/orbit_scroll.dart | 6 +- lib/features/songs/widgets/song_card.dart | 50 +++++- test/features/songs/orbit_scroll_test.dart | 41 +++++ test/features/songs/song_card_test.dart | 177 +++++++++++++++++++ 4 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 test/features/songs/song_card_test.dart diff --git a/lib/features/songs/widgets/orbit_scroll.dart b/lib/features/songs/widgets/orbit_scroll.dart index 009ba9db..71573505 100644 --- a/lib/features/songs/widgets/orbit_scroll.dart +++ b/lib/features/songs/widgets/orbit_scroll.dart @@ -625,12 +625,16 @@ class _OrbitScrollState extends State items.add( Positioned( + // Key the direct Stack child, not SongCard: as the visible window + // and back-to-front order advance, keeping the key here lets a + // surviving song's card State (and retained artwork) move between + // position slots instead of being disposed and rebuilt. + key: ValueKey(widget.songs[actualIndex].id), left: transform.position.x, top: transform.position.y, child: FractionalTranslation( translation: const Offset(-0.5, -0.5), child: SongCard( - key: ValueKey(widget.songs[actualIndex].id), song: widget.songs[actualIndex], scale: transform.scale, opacity: transform.opacity, diff --git a/lib/features/songs/widgets/song_card.dart b/lib/features/songs/widgets/song_card.dart index 9f96d3f6..a88a7444 100644 --- a/lib/features/songs/widgets/song_card.dart +++ b/lib/features/songs/widgets/song_card.dart @@ -87,10 +87,40 @@ class _SongCardState extends State { bool _queuedFlash = false; bool _favoriteFlash = false; + /// Retained card face. Orbit motion changes only scale/opacity, so keeping + /// this subtree identical between animation ticks avoids rebuilding the + /// artwork, gradients and text on every frame. + Widget? _cardFace; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Inherited widgets may have changed (theme, media query, etc.). + _cardFace = null; + } + + @override + void didUpdateWidget(covariant SongCard oldWidget) { + super.didUpdateWidget(oldWidget); + // Song equality compares only the id, so use identity here: metadata or + // artwork for the same id must refresh the retained face. + if (!identical(oldWidget.song, widget.song) || + oldWidget.isSelected != widget.isSelected || + oldWidget.isSelectionMode != widget.isSelectionMode || + oldWidget.isMultiSelected != widget.isMultiSelected || + oldWidget.artSizeBase != widget.artSizeBase || + oldWidget.artSizeLarge != widget.artSizeLarge || + oldWidget.artResolutionMultiplier != widget.artResolutionMultiplier || + oldWidget.artworkAbove != widget.artworkAbove) { + _cardFace = null; + } + } + @override Widget build(BuildContext context) { final artSize = widget.isSelected ? widget.artSizeLarge : widget.artSizeBase; + final cardFace = _cardFace ??= _buildCardFace(artSize); final cardWidth = widget.width ?? MediaQuery.sizeOf(context).width * widget.cardWidthRatio; @@ -172,17 +202,19 @@ class _SongCardState extends State { onHorizontalDragUpdate: dragUpdate, onHorizontalDragEnd: dragEnd, onHorizontalDragCancel: dragCancel, - child: RepaintBoundary( - child: Transform.scale( - scale: widget.scale.isFinite ? widget.scale : 1.0, - child: Opacity( - opacity: widget.opacity.isFinite ? widget.opacity.clamp(0.0, 1.0) : 1.0, + // Keep the changing scale/opacity outside the retained artwork: the + // RepaintBoundary caches the card face while the motion above it moves. + child: Transform.scale( + scale: widget.scale.isFinite ? widget.scale : 1.0, + child: Opacity( + opacity: widget.opacity.isFinite ? widget.opacity.clamp(0.0, 1.0) : 1.0, + child: RepaintBoundary( child: SizedBox( width: cardWidth, height: cardHeight, child: showSwipeOverlay - ? _buildSwipeStack(cardWidth, artSize) - : _buildCardFace(artSize), + ? _buildSwipeStack(cardWidth, cardFace) + : cardFace, ), ), ), @@ -369,7 +401,7 @@ class _SongCardState extends State { ); } - Widget _buildSwipeStack(double cardWidth, double artSize) { + Widget _buildSwipeStack(double cardWidth, Widget cardFace) { final queueProgress = (-_dragDx / 110).clamp(0.0, 1.0); final favoriteProgress = (_dragDx / 110).clamp(0.0, 1.0); final isFlashing = _queuedFlash || _favoriteFlash; @@ -458,7 +490,7 @@ class _SongCardState extends State { ] : null, ), - child: _buildCardFace(artSize), + child: cardFace, ), ), ), diff --git a/test/features/songs/orbit_scroll_test.dart b/test/features/songs/orbit_scroll_test.dart index abc2851b..fe621fc3 100644 --- a/test/features/songs/orbit_scroll_test.dart +++ b/test/features/songs/orbit_scroll_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flick/features/songs/widgets/orbit_scroll.dart'; import 'package:flick/features/songs/widgets/song_card.dart'; import 'package:flick/models/song.dart'; +import 'package:flick/widgets/common/cached_image_widget.dart'; final _songs = List.generate( 40, @@ -158,6 +159,46 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('songs retain card and artwork state as the orbit advances', ( + tester, + ) async { + setView(tester, const Size(411, 900)); + final pages = PageController(); + final orbit = OrbitScrollController(); + addTearDown(pages.dispose); + await tester.pumpWidget(host(pages, orbit: orbit, axis: Axis.vertical)); + + final cardStates = {}; + final imageStates = {}; + for (final element in find.byType(SongCard).evaluate()) { + final card = element.widget as SongCard; + final finder = find.byWidget(card); + cardStates[card.song.id] = tester.state(finder); + imageStates[card.song.id] = tester.state( + find.descendant(of: finder, matching: find.byType(CachedImageWidget)), + ); + } + + orbit.jumpToIndex(16, animate: false); + await tester.pump(); + var retained = 0; + for (final element in find.byType(SongCard).evaluate()) { + final card = element.widget as SongCard; + if (!cardStates.containsKey(card.song.id)) continue; + final finder = find.byWidget(card); + expect(tester.state(finder), same(cardStates[card.song.id])); + expect( + tester.state( + find.descendant(of: finder, matching: find.byType(CachedImageWidget)), + ), + same(imageStates[card.song.id]), + ); + retained++; + } + expect(retained, greaterThan(1)); + expect(tester.takeException(), isNull); + }); + testWidgets('short landscape cards fit with enlarged text', (tester) async { setView(tester, const Size(640, 240)); final pages = PageController(); diff --git a/test/features/songs/song_card_test.dart b/test/features/songs/song_card_test.dart new file mode 100644 index 00000000..35aa9c4b --- /dev/null +++ b/test/features/songs/song_card_test.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/features/songs/widgets/song_card.dart'; +import 'package:flick/models/song.dart'; +import 'package:flick/widgets/common/cached_image_widget.dart'; + +const _song = Song( + id: 'card-song', + title: 'Original title', + artist: 'Original artist', + duration: Duration(minutes: 4), + fileType: 'FLAC', +); + +Widget _host({ + Song song = _song, + double scale = 1, + double opacity = 1, + bool isSelected = false, + bool isSelectionMode = false, + bool isMultiSelected = false, + double artSizeLarge = 110, + double artResolutionMultiplier = 1.5, + VoidCallback? onTap, + VoidCallback? onSwipeLeft, + VoidCallback? onSwipeRight, +}) => MaterialApp( + home: Scaffold( + body: Center( + child: SongCard( + song: song, + width: 320, + scale: scale, + opacity: opacity, + isSelected: isSelected, + isSelectionMode: isSelectionMode, + isMultiSelected: isMultiSelected, + artSizeBase: 70, + artSizeLarge: artSizeLarge, + artResolutionMultiplier: artResolutionMultiplier, + swipeActionsEnabled: true, + onTap: onTap, + onSwipeLeft: onSwipeLeft, + onSwipeRight: onSwipeRight, + ), + ), + ), +); + +void main() { + testWidgets('motion retains the card face and its painted content', ( + tester, + ) async { + await tester.pumpWidget(_host(scale: 0.9, opacity: 0.8)); + final imageFinder = find.byType(CachedImageWidget); + final image = tester.widget(imageFinder); + final imageState = tester.state(imageFinder); + final boundaryFinder = find.descendant( + of: find.byType(SongCard), + matching: find.byType(RepaintBoundary), + ); + final boundary = tester.renderObject(boundaryFinder); + final paintedContent = boundary.debugLayer!.firstChild; + expect(paintedContent, isNotNull); + + await tester.pumpWidget(_host(scale: 0.8, opacity: 0.7)); + expect(tester.widget(imageFinder), same(image)); + expect(tester.state(imageFinder), same(imageState)); + expect(tester.renderObject(boundaryFinder), same(boundary)); + expect(boundary.debugLayer!.firstChild, same(paintedContent)); + + final opacity = tester.widget( + find + .descendant(of: find.byType(SongCard), matching: find.byType(Opacity)) + .first, + ); + expect(opacity.child, isA()); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'same-ID metadata and artwork updates refresh the retained face', + (tester) async { + await tester.pumpWidget(_host()); + final imageFinder = find.byType(CachedImageWidget); + final originalImage = tester.widget(imageFinder); + final imageState = tester.state(imageFinder); + final edited = _song.copyWith( + title: 'Edited title', + artist: 'Edited artist', + albumArt: '/missing/edited-cover.png', + duration: const Duration(minutes: 5), + fileType: 'WAV', + ); + // Song equality compares IDs, not the metadata displayed by the card. + expect(edited, _song); + + await tester.pumpWidget(_host(song: edited)); + expect(find.text('Original title'), findsNothing); + expect(find.text('Edited title'), findsOneWidget); + expect(find.text('Edited artist'), findsOneWidget); + expect(find.text('05:00'), findsOneWidget); + expect(find.text('WAV'), findsOneWidget); + final updatedImage = tester.widget(imageFinder); + expect(updatedImage, isNot(same(originalImage))); + expect(updatedImage.imagePath, '/missing/edited-cover.png'); + expect(tester.state(imageFinder), same(imageState)); + }, + ); + + testWidgets('selection and artwork preferences refresh the retained face', ( + tester, + ) async { + await tester.pumpWidget(_host()); + final imageFinder = find.byType(CachedImageWidget); + final imageState = tester.state(imageFinder); + expect(tester.widget(imageFinder).thumbnailWidth, 105); + + await tester.pumpWidget(_host(isSelected: true)); + expect(tester.widget(imageFinder).thumbnailWidth, 165); + expect(tester.state(imageFinder), same(imageState)); + + await tester.pumpWidget( + _host(isSelected: true, artSizeLarge: 120, artResolutionMultiplier: 2), + ); + expect(tester.widget(imageFinder).thumbnailWidth, 240); + expect(tester.state(imageFinder), same(imageState)); + expect(tester.takeException(), isNull); + }); + + testWidgets('multiselect changes refresh the retained face', (tester) async { + await tester.pumpWidget(_host()); + expect(find.byIcon(Icons.radio_button_unchecked_rounded), findsNothing); + + await tester.pumpWidget(_host(isSelectionMode: true)); + expect(find.byIcon(Icons.radio_button_unchecked_rounded), findsOneWidget); + + await tester.pumpWidget( + _host(isSelectionMode: true, isMultiSelected: true), + ); + expect(find.byIcon(Icons.radio_button_unchecked_rounded), findsNothing); + expect(find.byIcon(Icons.check_circle_rounded), findsOneWidget); + }); + + testWidgets('retained content uses current tap and swipe callbacks', ( + tester, + ) async { + var oldTaps = 0; + var taps = 0; + var queued = 0; + var favorites = 0; + await tester.pumpWidget(_host(onTap: () => oldTaps++)); + await tester.pumpWidget( + _host( + scale: 0.95, + onTap: () => taps++, + onSwipeLeft: () => queued++, + onSwipeRight: () => favorites++, + ), + ); + await tester.tap(find.byType(SongCard)); + expect(oldTaps, 0); + expect(taps, 1); + + await tester.drag(find.byType(SongCard), const Offset(-150, 0)); + await tester.pumpAndSettle(); + expect(queued, 1); + expect(favorites, 0); + + await tester.drag(find.byType(SongCard), const Offset(150, 0)); + await tester.pumpAndSettle(); + expect(queued, 1); + expect(favorites, 1); + expect(tester.takeException(), isNull); + }); +} From f904d3b4c796581355314b850a39f8701cc5f83c Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Fri, 6 Nov 2026 18:58:32 +0800 Subject: [PATCH 02/11] Add independent portrait and landscape orbit layout preferences Give each orientation its own persisted geometry, sizing, and depth settings, surfaced through a profile selector in orbit settings and used by the orbit renderer. Shared visuals like art resolution and path/glow toggles remain common across both profiles. --- .../screens/orbit_settings_screen.dart | 224 +++++++++++++----- lib/features/songs/widgets/orbit_scroll.dart | 91 +++++-- .../settings/orbit_settings_screen_test.dart | 49 ++++ .../orbit_layout_preferences_test.dart | 157 ++++++++++++ 4 files changed, 443 insertions(+), 78 deletions(-) create mode 100644 test/features/settings/orbit_settings_screen_test.dart create mode 100644 test/providers/orbit_layout_preferences_test.dart diff --git a/lib/features/settings/screens/orbit_settings_screen.dart b/lib/features/settings/screens/orbit_settings_screen.dart index 7437de04..25f8d10b 100644 --- a/lib/features/settings/screens/orbit_settings_screen.dart +++ b/lib/features/settings/screens/orbit_settings_screen.dart @@ -3,143 +3,246 @@ import 'package:flick/widgets/common/flick_snackbar.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flick/core/constants/app_constants.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; import 'package:flick/providers/app_preferences_provider.dart'; import 'package:flick/features/settings/widgets/settings_widgets.dart'; import 'package:flick/l10n/settings_l10n.dart'; -class OrbitSettingsScreen extends ConsumerWidget { +enum _OrbitLayoutProfile { portrait, landscape } + +class OrbitSettingsScreen extends ConsumerStatefulWidget { const OrbitSettingsScreen({super.key, this.initialSection}); final String? initialSection; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _OrbitSettingsScreenState(); +} + +class _OrbitSettingsScreenState extends ConsumerState { + _OrbitLayoutProfile _profile = _OrbitLayoutProfile.portrait; + bool _profileInitialized = false; + + bool get _landscape => _profile == _OrbitLayoutProfile.landscape; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_profileInitialized) return; + _profileInitialized = true; + _profile = AdaptiveLayout.isWide(MediaQuery.sizeOf(context)) + ? _OrbitLayoutProfile.landscape + : _OrbitLayoutProfile.portrait; + } + + @override + Widget build(BuildContext context) { final prefs = ref.watch(appPreferencesProvider); final notifier = ref.read(appPreferencesProvider.notifier); + final radiusRatio = _landscape + ? prefs.orbitLandscapeRadiusRatio + : prefs.orbitRadiusRatio; + final centerYRatio = _landscape + ? prefs.orbitLandscapeCenterYRatio + : prefs.orbitCenterYRatio; + final itemSpacing = _landscape + ? prefs.orbitLandscapeItemSpacing + : prefs.orbitItemSpacing; + final selectedScale = _landscape + ? prefs.orbitLandscapeSelectedScale + : prefs.orbitSelectedScale; + final depth = _landscape ? prefs.orbitLandscapeDepth : prefs.orbitDepth; + final visibleItems = _landscape + ? prefs.orbitLandscapeVisibleItems + : prefs.orbitVisibleItems; + final cardWidthRatio = _landscape + ? prefs.orbitLandscapeCardWidthRatio + : prefs.orbitCardWidthRatio; + return SettingsScaffold( title: l10n.englishForFilipino.customizeOrbital, - initialSection: initialSection, + initialSection: widget.initialSection, body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SettingsSectionHeader(l10n.englishForFilipino.geometry, sectionId: 'geometry'), + SettingsSectionHeader( + l10n.englishForFilipino.orbitLayoutProfile, + sectionId: 'orbitLayoutProfile', + ), + SettingsCard( + children: [ + SelectionSetting( + icon: LucideIcons.rectangleVertical, + title: l10n.englishForFilipino.portrait, + subtitle: l10n.orbitPortraitHint, + selected: !_landscape, + onTap: () { + setState(() => _profile = _OrbitLayoutProfile.portrait); + }, + ), + const SettingsDivider(), + SelectionSetting( + icon: LucideIcons.rectangleHorizontal, + title: l10n.englishForFilipino.landscape, + subtitle: l10n.orbitLandscapeHint, + selected: _landscape, + onTap: () { + setState(() => _profile = _OrbitLayoutProfile.landscape); + }, + ), + ], + ), + const SizedBox(height: AppConstants.spacingLg), + SettingsSectionHeader( + l10n.englishForFilipino.geometry, + sectionId: 'geometry', + ), SettingsCard( children: [ SliderSetting( icon: LucideIcons.radius, title: l10n.englishForFilipino.curvature, subtitle: l10n.curveOfTheOrbitArcHigher, - value: prefs.orbitRadiusRatio, - displayValue: prefs.orbitRadiusRatio.toStringAsFixed(2), + value: radiusRatio, + displayValue: radiusRatio.toStringAsFixed(2), min: 0.5, max: 2.0, divisions: 30, - onChanged: notifier.setOrbitRadiusRatio, + onChanged: _landscape + ? notifier.setOrbitLandscapeRadiusRatio + : notifier.setOrbitRadiusRatio, ), const SettingsDivider(), SliderSetting( icon: LucideIcons.moveVertical, title: l10n.englishForFilipino.verticalPosition, subtitle: l10n.whereTheFocalSongSitsVertically, - value: prefs.orbitCenterYRatio, - displayValue: '${(prefs.orbitCenterYRatio * 100).round()}%', + value: centerYRatio, + displayValue: '${(centerYRatio * 100).round()}%', min: 0.30, max: 0.60, divisions: 30, - onChanged: notifier.setOrbitCenterYRatio, - ), - const SettingsDivider(), - SliderSetting( - icon: LucideIcons.moveHorizontal, - title: l10n.englishForFilipino.horizontalReach, - subtitle: l10n.howFarTheArcOpensFrom, - value: prefs.orbitCenterOffsetRatio, - displayValue: prefs.orbitCenterOffsetRatio.toStringAsFixed(2), - min: -1.0, - max: 0.0, - divisions: 20, - onChanged: notifier.setOrbitCenterOffsetRatio, + onChanged: _landscape + ? notifier.setOrbitLandscapeCenterYRatio + : notifier.setOrbitCenterYRatio, ), + if (!_landscape) ...[ + const SettingsDivider(), + SliderSetting( + icon: LucideIcons.moveHorizontal, + title: l10n.englishForFilipino.horizontalReach, + subtitle: l10n.howFarTheArcOpensFrom, + value: prefs.orbitCenterOffsetRatio, + displayValue: prefs.orbitCenterOffsetRatio.toStringAsFixed(2), + min: -1.0, + max: 0.0, + divisions: 20, + onChanged: notifier.setOrbitCenterOffsetRatio, + ), + ], const SettingsDivider(), SliderSetting( icon: LucideIcons.ruler, title: l10n.englishForFilipino.itemSpacing, subtitle: l10n.distanceBetweenSongsAlongTheArc, - value: prefs.orbitItemSpacing, - displayValue: prefs.orbitItemSpacing.toStringAsFixed(2), + value: itemSpacing, + displayValue: itemSpacing.toStringAsFixed(2), min: 0.15, max: 0.50, divisions: 35, - onChanged: notifier.setOrbitItemSpacing, + onChanged: _landscape + ? notifier.setOrbitLandscapeItemSpacing + : notifier.setOrbitItemSpacing, ), ], ), const SizedBox(height: AppConstants.spacingLg), - SettingsSectionHeader(l10n.englishForFilipino.sizing, sectionId: 'sizing'), + SettingsSectionHeader( + l10n.englishForFilipino.sizing, + sectionId: 'sizing', + ), SettingsCard( children: [ - SliderSetting( - icon: LucideIcons.expand, - title: l10n.englishForFilipino.cardSize, - subtitle: l10n.baseAlbumArtSizeForEach, - value: prefs.orbitCardArtSize, - displayValue: '${prefs.orbitCardArtSize.round()}px', - min: 48, - max: 120, - divisions: 72, - onChanged: notifier.setOrbitCardArtSize, - ), - const SettingsDivider(), + if (!_landscape) ...[ + SliderSetting( + icon: LucideIcons.expand, + title: l10n.englishForFilipino.cardSize, + subtitle: l10n.baseAlbumArtSizeForEach, + value: prefs.orbitCardArtSize, + displayValue: '${prefs.orbitCardArtSize.round()}px', + min: 48, + max: 120, + divisions: 72, + onChanged: notifier.setOrbitCardArtSize, + ), + const SettingsDivider(), + ], SliderSetting( icon: LucideIcons.arrowLeftRight, title: l10n.englishForFilipino.cardWidth, subtitle: l10n.howWideEachCardSpansAcross, - value: prefs.orbitCardWidthRatio, - displayValue: '${(prefs.orbitCardWidthRatio * 100).round()}%', + value: cardWidthRatio, + displayValue: '${(cardWidthRatio * 100).round()}%', min: 0.5, max: 0.85, divisions: 35, - onChanged: notifier.setOrbitCardWidthRatio, + onChanged: _landscape + ? notifier.setOrbitLandscapeCardWidthRatio + : notifier.setOrbitCardWidthRatio, ), const SettingsDivider(), _VisibleItemsRow( - value: prefs.orbitVisibleItems, - onChanged: notifier.setOrbitVisibleItems, + value: visibleItems, + onChanged: _landscape + ? notifier.setOrbitLandscapeVisibleItems + : notifier.setOrbitVisibleItems, ), ], ), const SizedBox(height: AppConstants.spacingLg), - SettingsSectionHeader(l10n.englishForFilipino.depth, sectionId: 'depth'), + SettingsSectionHeader( + l10n.englishForFilipino.depth, + sectionId: 'depth', + ), SettingsCard( children: [ SliderSetting( icon: LucideIcons.maximize, title: l10n.englishForFilipino.selectedSize, subtitle: l10n.scaleOfTheCenteredFocusedSong, - value: prefs.orbitSelectedScale, - displayValue: prefs.orbitSelectedScale.toStringAsFixed(2), + value: selectedScale, + displayValue: selectedScale.toStringAsFixed(2), min: 1.0, max: 1.8, divisions: 16, - onChanged: notifier.setOrbitSelectedScale, + onChanged: _landscape + ? notifier.setOrbitLandscapeSelectedScale + : notifier.setOrbitSelectedScale, ), const SettingsDivider(), SliderSetting( icon: LucideIcons.chevronsDown, title: l10n.englishForFilipino.depth, subtitle: l10n.howMuchSideCardsShrinkAway, - value: prefs.orbitDepth, - displayValue: '${(prefs.orbitDepth * 100).round()}%', + value: depth, + displayValue: '${(depth * 100).round()}%', min: 0.0, max: 1.0, divisions: 20, - onChanged: notifier.setOrbitDepth, + onChanged: _landscape + ? notifier.setOrbitLandscapeDepth + : notifier.setOrbitDepth, ), ], ), const SizedBox(height: AppConstants.spacingLg), - SettingsSectionHeader(l10n.englishForFilipino.artResolution, sectionId: 'artResolution'), + SettingsSectionHeader( + l10n.englishForFilipino.artResolution, + tag: l10n.englishForFilipino.orbitSharedVisuals, + sectionId: 'artResolution', + ), SettingsCard( children: [ SelectionSetting( @@ -176,7 +279,11 @@ class OrbitSettingsScreen extends ConsumerWidget { ], ), const SizedBox(height: AppConstants.spacingLg), - SettingsSectionHeader(l10n.englishForFilipino.visuals, sectionId: 'visuals'), + SettingsSectionHeader( + l10n.englishForFilipino.visuals, + tag: l10n.englishForFilipino.orbitSharedVisuals, + sectionId: 'visuals', + ), SettingsCard( children: [ ToggleSetting( @@ -202,13 +309,18 @@ class OrbitSettingsScreen extends ConsumerWidget { NavigationSetting( icon: LucideIcons.refreshCw, title: l10n.englishForFilipino.resetToDefaults, - subtitle: l10n.restoreTheOriginalOrbitalLayout, + subtitle: l10n.restoreThisProfilesOrbitLayout, onTap: () { - notifier.resetOrbitSettings(); + final landscape = _landscape; + notifier.resetOrbitLayout(landscape: landscape); ScaffoldMessenger.of(context).showFlickSnackBar( SnackBar( - content: Text(l10n.orbitalSettingsReset), - duration: Duration(seconds: 2), + content: Text( + landscape + ? l10n.landscapeLayoutReset + : l10n.portraitLayoutReset, + ), + duration: const Duration(seconds: 2), ), ); }, diff --git a/lib/features/songs/widgets/orbit_scroll.dart b/lib/features/songs/widgets/orbit_scroll.dart index 71573505..ea113d1d 100644 --- a/lib/features/songs/widgets/orbit_scroll.dart +++ b/lib/features/songs/widgets/orbit_scroll.dart @@ -431,16 +431,32 @@ class _OrbitScrollState extends State } // Phone landscape leaves a much shorter stage than a tablet. Keep // the same orbit metaphor, but lift and shrink it so the flanking - // cards are not pushed off the bottom of the window. + // cards are not pushed off the bottom of the window. The lift is a + // runtime fit only; the saved landscape preference is never rewritten. final compactHorizontal = horizontal && size.height < 420; final orbitRadius = horizontal - ? size.width * widget.radiusRatio.clamp(0.6, 1.4) + ? size.width * widget.radiusRatio.clamp(0.5, 2.0) : size.width * widget.radiusRatio; final orbitCenterX = horizontal ? size.width / 2 : size.width * widget.centerOffsetRatio; + final landscapeCardHeight = horizontal + ? _landscapeCardHeight(size, compactHorizontal) + : 0.0; + final landscapeCardWidth = horizontal + ? _landscapeCardWidth( + size, + landscapeCardHeight, + compactHorizontal, + ) + : 0.0; final orbitCenterY = horizontal - ? size.height * (compactHorizontal ? 0.44 : 0.48) + orbitRadius + ? _landscapeFocalCenterY( + size, + orbitRadius, + landscapeCardHeight, + compactHorizontal, + ) : size.height * widget.centerYRatio; _dragExtent = horizontal ? (size.width * 0.28).clamp(140.0, 280.0) @@ -486,6 +502,8 @@ class _OrbitScrollState extends State orbitCenterX, orbitCenterY, orbitRadius, + cardWidth: horizontal ? landscapeCardWidth : null, + cardHeight: horizontal ? landscapeCardHeight : null, ), ), ); @@ -550,27 +568,56 @@ class _OrbitScrollState extends State ); } - List _buildSongItems(double centerX, double centerY, double radius) { - final List items = []; + /// Unscaled card height for a horizontal (landscape) orbit. The selected + /// card is multiplied by [OrbitScroll.selectedScale] at paint time, so this + /// value is divided by that scale to keep the focused card fitting the stage. + double _landscapeCardHeight(Size size, bool compact) { + final cardScale = math.max(1.0, widget.selectedScale); + return compact + ? size.height * 0.72 / cardScale + : math.min(380.0, size.height * 0.85) / cardScale; + } - final horizontal = widget.scrollDirection == Axis.horizontal; - final compactHorizontal = horizontal && _layoutSize!.height < 420; + double _landscapeCardWidth(Size size, double cardHeight, bool compact) { final cardScale = math.max(1.0, widget.selectedScale); final viewportCardWidth = - (_layoutSize!.width * widget.cardWidthRatio * 0.5) / cardScale; - final compactCardHeight = _layoutSize!.height * 0.72 / cardScale; - final cardWidth = horizontal - ? compactHorizontal - ? math - .min(viewportCardWidth, compactCardHeight * 1.05) - .clamp(88.0, 280.0) - : viewportCardWidth.clamp(160.0, 280.0) - : null; - final cardHeight = horizontal - ? compactHorizontal - ? compactCardHeight - : math.min(380.0, _layoutSize!.height * 0.85) / cardScale - : null; + size.width * widget.cardWidthRatio * 0.5 / cardScale; + return compact + ? math.min(viewportCardWidth, cardHeight * 1.05).clamp(88.0, 280.0) + : viewportCardWidth.clamp(160.0, 280.0); + } + + /// The horizontal orbit circle sits below the visible arc. `centerYRatio` + /// names the focal song's vertical position, then the runtime clamp keeps + /// that song's full scaled height inside the stage. + double _landscapeFocalCenterY( + Size size, + double radius, + double cardHeight, + bool compact, + ) { + final cardScale = math.max(1.0, widget.selectedScale); + final selectedCardHeight = cardHeight * cardScale; + final focalRatio = widget.centerYRatio - (compact ? 0.04 : 0.0); + final desiredFocalY = size.height * focalRatio; + final minFocalY = selectedCardHeight / 2 + 8; + final maxFocalY = size.height - selectedCardHeight / 2 - 8; + final focalY = maxFocalY < minFocalY + ? size.height / 2 + : desiredFocalY.clamp(minFocalY, maxFocalY); + return focalY + radius; + } + + List _buildSongItems( + double centerX, + double centerY, + double radius, { + double? cardWidth, + double? cardHeight, + }) { + final List items = []; + + final horizontal = widget.scrollDirection == Axis.horizontal; final centerIndex = _scrollOffset.value.round(); @@ -606,7 +653,7 @@ class _OrbitScrollState extends State } else { scale = distantScale - (distanceFromCenter - 2.0) * 0.12; } - scale = scale.clamp(0.0, 1.25); + scale = scale.clamp(0.0, math.max(1.25, widget.selectedScale)); if (scale < 0.1) continue; diff --git a/test/features/settings/orbit_settings_screen_test.dart b/test/features/settings/orbit_settings_screen_test.dart new file mode 100644 index 00000000..248c59a3 --- /dev/null +++ b/test/features/settings/orbit_settings_screen_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flick/core/theme/app_theme.dart'; +import 'package:flick/features/settings/screens/orbit_settings_screen.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/providers/providers.dart'; + +Widget _host() => ProviderScope( + overrides: [currentSongProvider.overrideWithValue(null)], + child: MaterialApp( + theme: AppTheme.darkTheme, + locale: kFallbackLocale, + supportedLocales: kSupportedLocales, + localizationsDelegates: kAppLocalizationsDelegates, + home: const OrbitSettingsScreen(), + ), +); + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('profile selector switches without rotating the window', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(411, 900); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(_host()); + await tester.pumpAndSettle(); + + final copy = lookupFlickLocalizations(kFallbackLocale); + expect(find.text(copy.portrait), findsOneWidget); + expect(find.text(copy.landscape), findsOneWidget); + expect(find.text(copy.horizontalReach), findsOneWidget); + expect(find.text(copy.cardSize), findsOneWidget); + + await tester.tap(find.text(copy.landscape)); + await tester.pumpAndSettle(); + + expect(find.text(copy.horizontalReach), findsNothing); + expect(find.text(copy.cardSize), findsNothing); + expect(find.text(copy.cardWidth), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/providers/orbit_layout_preferences_test.dart b/test/providers/orbit_layout_preferences_test.dart new file mode 100644 index 00000000..6f452d53 --- /dev/null +++ b/test/providers/orbit_layout_preferences_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flick/providers/app_preferences_provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future newContainer() async { + final container = ProviderContainer(); + addTearDown(container.dispose); + await container.read(appPreferencesProvider.notifier).loaded; + return container; + } + + test( + 'landscape layout keeps the previous landscape render as defaults', + () async { + final container = await newContainer(); + final prefs = container.read(appPreferencesProvider); + + expect(prefs.orbitLandscapeRadiusRatio, 1.0); + expect(prefs.orbitLandscapeCenterYRatio, 0.48); + expect(prefs.orbitLandscapeItemSpacing, 0.28); + expect(prefs.orbitLandscapeSelectedScale, 1.25); + expect(prefs.orbitLandscapeDepth, 0.75); + expect(prefs.orbitLandscapeVisibleItems, 5); + expect(prefs.orbitLandscapeCardWidthRatio, 0.68); + }, + ); + + test('portrait and landscape layouts persist independently', () async { + final container = await newContainer(); + final notifier = container.read(appPreferencesProvider.notifier); + + await notifier.setOrbitRadiusRatio(0.82); + await notifier.setOrbitCenterYRatio(0.36); + await notifier.setOrbitItemSpacing(0.19); + await notifier.setOrbitSelectedScale(1.4); + await notifier.setOrbitDepth(0.5); + await notifier.setOrbitVisibleItems(7); + await notifier.setOrbitCardWidthRatio(0.58); + + await notifier.setOrbitLandscapeRadiusRatio(1.36); + await notifier.setOrbitLandscapeCenterYRatio(0.56); + await notifier.setOrbitLandscapeItemSpacing(0.41); + await notifier.setOrbitLandscapeSelectedScale(1.7); + await notifier.setOrbitLandscapeDepth(0.3); + await notifier.setOrbitLandscapeVisibleItems(9); + await notifier.setOrbitLandscapeCardWidthRatio(0.78); + + final restored = await newContainer(); + final prefs = restored.read(appPreferencesProvider); + expect(prefs.orbitRadiusRatio, 0.82); + expect(prefs.orbitCenterYRatio, 0.36); + expect(prefs.orbitItemSpacing, 0.19); + expect(prefs.orbitSelectedScale, 1.4); + expect(prefs.orbitDepth, 0.5); + expect(prefs.orbitVisibleItems, 7); + expect(prefs.orbitCardWidthRatio, 0.58); + expect(prefs.orbitLandscapeRadiusRatio, 1.36); + expect(prefs.orbitLandscapeCenterYRatio, 0.56); + expect(prefs.orbitLandscapeItemSpacing, 0.41); + expect(prefs.orbitLandscapeSelectedScale, 1.7); + expect(prefs.orbitLandscapeDepth, 0.3); + expect(prefs.orbitLandscapeVisibleItems, 9); + expect(prefs.orbitLandscapeCardWidthRatio, 0.78); + }); + + test('saved portrait settings do not leak into landscape defaults', () async { + SharedPreferences.setMockInitialValues({ + 'orbit_radius_ratio': 0.74, + 'orbit_center_offset_ratio': -0.8, + 'orbit_center_y_ratio': 0.33, + 'orbit_item_spacing': 0.44, + 'orbit_selected_scale': 1.6, + 'orbit_depth': 0.2, + 'orbit_visible_items': 9, + 'orbit_card_art_size': 96.0, + 'orbit_card_width_ratio': 0.8, + }); + + final container = await newContainer(); + final prefs = container.read(appPreferencesProvider); + expect(prefs.orbitRadiusRatio, 0.74); + expect(prefs.orbitCenterYRatio, 0.33); + expect(prefs.orbitLandscapeRadiusRatio, 1.0); + expect(prefs.orbitLandscapeCenterYRatio, 0.48); + expect(prefs.orbitLandscapeItemSpacing, 0.28); + expect(prefs.orbitLandscapeSelectedScale, 1.25); + expect(prefs.orbitLandscapeDepth, 0.75); + expect(prefs.orbitLandscapeVisibleItems, 5); + expect(prefs.orbitLandscapeCardWidthRatio, 0.68); + }); + + test( + 'resetting one layout leaves the other and shared visuals untouched', + () async { + final container = await newContainer(); + final notifier = container.read(appPreferencesProvider.notifier); + + await notifier.setOrbitRadiusRatio(0.82); + await notifier.setOrbitVisibleItems(7); + await notifier.setOrbitLandscapeRadiusRatio(1.36); + await notifier.setOrbitLandscapeCenterYRatio(0.56); + await notifier.setOrbitLandscapeVisibleItems(9); + await notifier.setOrbitArtResolutionMultiplier(3.0); + await notifier.setOrbitShowPath(false); + await notifier.setOrbitShowGlow(false); + + await notifier.resetOrbitLayout(landscape: true); + + var prefs = container.read(appPreferencesProvider); + expect(prefs.orbitLandscapeRadiusRatio, 1.0); + expect(prefs.orbitLandscapeCenterYRatio, 0.48); + expect(prefs.orbitLandscapeItemSpacing, 0.28); + expect(prefs.orbitLandscapeSelectedScale, 1.25); + expect(prefs.orbitLandscapeDepth, 0.75); + expect(prefs.orbitLandscapeVisibleItems, 5); + expect(prefs.orbitLandscapeCardWidthRatio, 0.68); + expect(prefs.orbitRadiusRatio, 0.82); + expect(prefs.orbitVisibleItems, 7); + expect(prefs.orbitArtResolutionMultiplier, 3.0); + expect(prefs.orbitShowPath, isFalse); + expect(prefs.orbitShowGlow, isFalse); + + var stored = await SharedPreferences.getInstance(); + expect(stored.getDouble('orbit_landscape_radius_ratio'), isNull); + expect(stored.getDouble('orbit_radius_ratio'), 0.82); + expect(stored.getDouble('orbit_art_resolution_multiplier'), 3.0); + expect(stored.getBool('orbit_show_path'), isFalse); + + await notifier.resetOrbitLayout(landscape: false); + + prefs = container.read(appPreferencesProvider); + expect(prefs.orbitRadiusRatio, 1.0); + expect(prefs.orbitCenterOffsetRatio, -0.5); + expect(prefs.orbitCenterYRatio, 0.42); + expect(prefs.orbitItemSpacing, 0.28); + expect(prefs.orbitSelectedScale, 1.25); + expect(prefs.orbitDepth, 0.75); + expect(prefs.orbitVisibleItems, 5); + expect(prefs.orbitCardArtSize, 64.0); + expect(prefs.orbitCardWidthRatio, 0.68); + expect(prefs.orbitLandscapeRadiusRatio, 1.0); + expect(prefs.orbitArtResolutionMultiplier, 3.0); + + stored = await SharedPreferences.getInstance(); + expect(stored.getDouble('orbit_radius_ratio'), isNull); + expect(stored.getDouble('orbit_landscape_radius_ratio'), isNull); + expect(stored.getDouble('orbit_art_resolution_multiplier'), 3.0); + }, + ); +} From 21b463147614922223cf83dc6f5615023cf196a2 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Fri, 6 Nov 2026 18:58:43 +0800 Subject: [PATCH 03/11] Add separate landscape orbit layout preferences Persist landscape-specific geometry, sizing, depth, and spacing settings alongside the existing portrait values, with per-orientation reset support in the preferences notifier and service. --- lib/providers/app_preferences_provider.dart | 117 +++++++++++-- lib/services/app_preferences_service.dart | 175 +++++++++++++++----- 2 files changed, 233 insertions(+), 59 deletions(-) diff --git a/lib/providers/app_preferences_provider.dart b/lib/providers/app_preferences_provider.dart index 83d6fb5d..48c06697 100644 --- a/lib/providers/app_preferences_provider.dart +++ b/lib/providers/app_preferences_provider.dart @@ -94,9 +94,7 @@ class AppPreferencesNotifier extends Notifier { Future setShowUsbVolumeOnMenu(bool value) async { if (state.showUsbVolumeOnMenu == value) return; state = state.copyWith(showUsbVolumeOnMenu: value); - await ref - .read(appPreferencesServiceProvider) - .setShowUsbVolumeOnMenu(value); + await ref.read(appPreferencesServiceProvider).setShowUsbVolumeOnMenu(value); } Future setShowUsbVolumeOnSettings(bool value) async { @@ -408,7 +406,9 @@ class AppPreferencesNotifier extends Notifier { Future setWidgetCompactBgOpacity(int value) async { if (state.widgetCompactBgOpacity == value) return; state = state.copyWith(widgetCompactBgOpacity: value); - await ref.read(appPreferencesServiceProvider).setWidgetCompactBgOpacity(value); + await ref + .read(appPreferencesServiceProvider) + .setWidgetCompactBgOpacity(value); } Future setWidgetCompactShowAlbumArt(bool value) async { @@ -492,9 +492,7 @@ class AppPreferencesNotifier extends Notifier { Future setLeftTopActionButton(String value) async { if (state.leftTopActionButton == value) return; state = state.copyWith(leftTopActionButton: value); - await ref - .read(appPreferencesServiceProvider) - .setLeftTopActionButton(value); + await ref.read(appPreferencesServiceProvider).setLeftTopActionButton(value); } Future setRightTopActionButton(String value) async { @@ -522,9 +520,7 @@ class AppPreferencesNotifier extends Notifier { Future setGlanceCardMinimized(bool value) async { if (state.glanceCardMinimized == value) return; state = state.copyWith(glanceCardMinimized: value); - await ref - .read(appPreferencesServiceProvider) - .setGlanceCardMinimized(value); + await ref.read(appPreferencesServiceProvider).setGlanceCardMinimized(value); } Future setReplaceAlbumWithBitPerfectCapsule(bool value) async { @@ -538,7 +534,9 @@ class AppPreferencesNotifier extends Notifier { Future setAlbumsStretchArtwork(bool value) async { if (state.albumsStretchArtwork == value) return; state = state.copyWith(albumsStretchArtwork: value); - await ref.read(appPreferencesServiceProvider).setAlbumsStretchArtwork(value); + await ref + .read(appPreferencesServiceProvider) + .setAlbumsStretchArtwork(value); } Future setAnimatedAlbumArt(bool value) async { @@ -700,9 +698,7 @@ class AppPreferencesNotifier extends Notifier { Future setBtLdacBitsPerSample(int value) async { if (state.btLdacBitsPerSample == value) return; state = state.copyWith(btLdacBitsPerSample: value); - await ref - .read(appPreferencesServiceProvider) - .setBtLdacBitsPerSample(value); + await ref.read(appPreferencesServiceProvider).setBtLdacBitsPerSample(value); } Future setFloatingPlayerEnabled(bool value) async { @@ -794,9 +790,7 @@ class AppPreferencesNotifier extends Notifier { Future setOrbitCardWidthRatio(double value) async { if (state.orbitCardWidthRatio == value) return; state = state.copyWith(orbitCardWidthRatio: value); - await ref - .read(appPreferencesServiceProvider) - .setOrbitCardWidthRatio(value); + await ref.read(appPreferencesServiceProvider).setOrbitCardWidthRatio(value); } Future setOrbitArtResolutionMultiplier(double value) async { @@ -819,6 +813,60 @@ class AppPreferencesNotifier extends Notifier { await ref.read(appPreferencesServiceProvider).setOrbitShowGlow(value); } + Future setOrbitLandscapeRadiusRatio(double value) async { + if (state.orbitLandscapeRadiusRatio == value) return; + state = state.copyWith(orbitLandscapeRadiusRatio: value); + await ref + .read(appPreferencesServiceProvider) + .setOrbitLandscapeRadiusRatio(value); + } + + Future setOrbitLandscapeCenterYRatio(double value) async { + if (state.orbitLandscapeCenterYRatio == value) return; + state = state.copyWith(orbitLandscapeCenterYRatio: value); + await ref + .read(appPreferencesServiceProvider) + .setOrbitLandscapeCenterYRatio(value); + } + + Future setOrbitLandscapeItemSpacing(double value) async { + if (state.orbitLandscapeItemSpacing == value) return; + state = state.copyWith(orbitLandscapeItemSpacing: value); + await ref + .read(appPreferencesServiceProvider) + .setOrbitLandscapeItemSpacing(value); + } + + Future setOrbitLandscapeSelectedScale(double value) async { + if (state.orbitLandscapeSelectedScale == value) return; + state = state.copyWith(orbitLandscapeSelectedScale: value); + await ref + .read(appPreferencesServiceProvider) + .setOrbitLandscapeSelectedScale(value); + } + + Future setOrbitLandscapeDepth(double value) async { + if (state.orbitLandscapeDepth == value) return; + state = state.copyWith(orbitLandscapeDepth: value); + await ref.read(appPreferencesServiceProvider).setOrbitLandscapeDepth(value); + } + + Future setOrbitLandscapeVisibleItems(int value) async { + if (state.orbitLandscapeVisibleItems == value) return; + state = state.copyWith(orbitLandscapeVisibleItems: value); + await ref + .read(appPreferencesServiceProvider) + .setOrbitLandscapeVisibleItems(value); + } + + Future setOrbitLandscapeCardWidthRatio(double value) async { + if (state.orbitLandscapeCardWidthRatio == value) return; + state = state.copyWith(orbitLandscapeCardWidthRatio: value); + await ref + .read(appPreferencesServiceProvider) + .setOrbitLandscapeCardWidthRatio(value); + } + Future setStreaksEnabled(bool value) async { if (state.streaksEnabled == value) return; state = state.copyWith(streaksEnabled: value); @@ -861,6 +909,34 @@ class AppPreferencesNotifier extends Notifier { .setSongTileThumbnailMode(value); } + Future resetOrbitLayout({required bool landscape}) async { + if (landscape) { + state = state.copyWith( + orbitLandscapeRadiusRatio: 1.0, + orbitLandscapeCenterYRatio: 0.48, + orbitLandscapeItemSpacing: 0.28, + orbitLandscapeSelectedScale: 1.25, + orbitLandscapeDepth: 0.75, + orbitLandscapeVisibleItems: 5, + orbitLandscapeCardWidthRatio: 0.68, + ); + await ref.read(appPreferencesServiceProvider).clearOrbitLandscapeLayout(); + return; + } + state = state.copyWith( + orbitRadiusRatio: 1.0, + orbitCenterOffsetRatio: -0.5, + orbitCenterYRatio: 0.42, + orbitItemSpacing: 0.28, + orbitSelectedScale: 1.25, + orbitDepth: 0.75, + orbitVisibleItems: 5, + orbitCardArtSize: 64.0, + orbitCardWidthRatio: 0.68, + ); + await ref.read(appPreferencesServiceProvider).clearOrbitPortraitLayout(); + } + Future resetOrbitSettings() async { state = state.copyWith( orbitRadiusRatio: 1.0, @@ -875,6 +951,13 @@ class AppPreferencesNotifier extends Notifier { orbitArtResolutionMultiplier: 2.0, orbitShowPath: true, orbitShowGlow: true, + orbitLandscapeRadiusRatio: 1.0, + orbitLandscapeCenterYRatio: 0.48, + orbitLandscapeItemSpacing: 0.28, + orbitLandscapeSelectedScale: 1.25, + orbitLandscapeDepth: 0.75, + orbitLandscapeVisibleItems: 5, + orbitLandscapeCardWidthRatio: 0.68, ); await ref.read(appPreferencesServiceProvider).clearOrbitSettings(); } diff --git a/lib/services/app_preferences_service.dart b/lib/services/app_preferences_service.dart index 2dddb6b2..c76eec49 100644 --- a/lib/services/app_preferences_service.dart +++ b/lib/services/app_preferences_service.dart @@ -107,12 +107,20 @@ class AppPreferences { final double orbitArtResolutionMultiplier; final bool orbitShowPath; final bool orbitShowGlow; + final double orbitLandscapeRadiusRatio; + final double orbitLandscapeCenterYRatio; + final double orbitLandscapeItemSpacing; + final double orbitLandscapeSelectedScale; + final double orbitLandscapeDepth; + final int orbitLandscapeVisibleItems; + final double orbitLandscapeCardWidthRatio; final bool streaksEnabled; final bool showMoreFromArtist; final bool showMoreArtists; final bool detailHeaderArtExpanded; final bool detailHeaderCenteredTitle; - final String songTileThumbnailMode; // 'artwork', 'trackNumber', or 'trackNumberOnArt' + final String + songTileThumbnailMode; // 'artwork', 'trackNumber', or 'trackNumberOnArt' final bool extendedVolumeEnabled; final String replayGainMode; // 'off', 'track', or 'album' final String lyricsTextAlign; // 'left', 'center', or 'right' @@ -227,6 +235,13 @@ class AppPreferences { this.orbitArtResolutionMultiplier = 2.0, this.orbitShowPath = true, this.orbitShowGlow = true, + this.orbitLandscapeRadiusRatio = 1.0, + this.orbitLandscapeCenterYRatio = 0.48, + this.orbitLandscapeItemSpacing = 0.28, + this.orbitLandscapeSelectedScale = 1.25, + this.orbitLandscapeDepth = 0.75, + this.orbitLandscapeVisibleItems = 5, + this.orbitLandscapeCardWidthRatio = 0.68, this.streaksEnabled = true, this.showMoreFromArtist = true, this.showMoreArtists = true, @@ -348,6 +363,13 @@ class AppPreferences { double? orbitArtResolutionMultiplier, bool? orbitShowPath, bool? orbitShowGlow, + double? orbitLandscapeRadiusRatio, + double? orbitLandscapeCenterYRatio, + double? orbitLandscapeItemSpacing, + double? orbitLandscapeSelectedScale, + double? orbitLandscapeDepth, + int? orbitLandscapeVisibleItems, + double? orbitLandscapeCardWidthRatio, bool? streaksEnabled, bool? showMoreFromArtist, bool? showMoreArtists, @@ -373,8 +395,7 @@ class AppPreferences { showBrowseMore: showBrowseMore ?? this.showBrowseMore, showQuickAccess: showQuickAccess ?? this.showQuickAccess, showEngineSelector: showEngineSelector ?? this.showEngineSelector, - showUsbVolumeOnMenu: - showUsbVolumeOnMenu ?? this.showUsbVolumeOnMenu, + showUsbVolumeOnMenu: showUsbVolumeOnMenu ?? this.showUsbVolumeOnMenu, showUsbVolumeOnSettings: showUsbVolumeOnSettings ?? this.showUsbVolumeOnSettings, crossfadeEnabled: crossfadeEnabled ?? this.crossfadeEnabled, @@ -454,8 +475,7 @@ class AppPreferences { replaceAlbumWithBitPerfectCapsule: replaceAlbumWithBitPerfectCapsule ?? this.replaceAlbumWithBitPerfectCapsule, - albumsStretchArtwork: - albumsStretchArtwork ?? this.albumsStretchArtwork, + albumsStretchArtwork: albumsStretchArtwork ?? this.albumsStretchArtwork, animatedAlbumArt: animatedAlbumArt ?? this.animatedAlbumArt, folderGridPageSize: folderGridPageSize ?? this.folderGridPageSize, lastSeenChangelogVersion: @@ -481,26 +501,21 @@ class AppPreferences { pauseOnUsbDacDisconnect ?? this.pauseOnUsbDacDisconnect, pauseOnBluetoothConnect: pauseOnBluetoothConnect ?? this.pauseOnBluetoothConnect, - pauseOnUsbDacConnect: - pauseOnUsbDacConnect ?? this.pauseOnUsbDacConnect, + pauseOnUsbDacConnect: pauseOnUsbDacConnect ?? this.pauseOnUsbDacConnect, preferredBluetoothDevice: preferredBluetoothDevice ?? this.preferredBluetoothDevice, btPreferredCodec: btPreferredCodec ?? this.btPreferredCodec, btLdacBitrate: btLdacBitrate ?? this.btLdacBitrate, - btAbsoluteVolumeSync: - btAbsoluteVolumeSync ?? this.btAbsoluteVolumeSync, - btEnableCodecControl: - btEnableCodecControl ?? this.btEnableCodecControl, + btAbsoluteVolumeSync: btAbsoluteVolumeSync ?? this.btAbsoluteVolumeSync, + btEnableCodecControl: btEnableCodecControl ?? this.btEnableCodecControl, btSampleRate: btSampleRate ?? this.btSampleRate, - btLdacBitsPerSample: - btLdacBitsPerSample ?? this.btLdacBitsPerSample, + btLdacBitsPerSample: btLdacBitsPerSample ?? this.btLdacBitsPerSample, floatingPlayerEnabled: floatingPlayerEnabled ?? this.floatingPlayerEnabled, floatingIslandEnabled: floatingIslandEnabled ?? this.floatingIslandEnabled, autoFocusSearch: autoFocusSearch ?? this.autoFocusSearch, - searchPlaybackMode: - searchPlaybackMode ?? this.searchPlaybackMode, + searchPlaybackMode: searchPlaybackMode ?? this.searchPlaybackMode, refreshRateMode: refreshRateMode ?? this.refreshRateMode, visualizerEnabled: visualizerEnabled ?? this.visualizerEnabled, orbitRadiusRatio: orbitRadiusRatio ?? this.orbitRadiusRatio, @@ -517,6 +532,19 @@ class AppPreferences { orbitArtResolutionMultiplier ?? this.orbitArtResolutionMultiplier, orbitShowPath: orbitShowPath ?? this.orbitShowPath, orbitShowGlow: orbitShowGlow ?? this.orbitShowGlow, + orbitLandscapeRadiusRatio: + orbitLandscapeRadiusRatio ?? this.orbitLandscapeRadiusRatio, + orbitLandscapeCenterYRatio: + orbitLandscapeCenterYRatio ?? this.orbitLandscapeCenterYRatio, + orbitLandscapeItemSpacing: + orbitLandscapeItemSpacing ?? this.orbitLandscapeItemSpacing, + orbitLandscapeSelectedScale: + orbitLandscapeSelectedScale ?? this.orbitLandscapeSelectedScale, + orbitLandscapeDepth: orbitLandscapeDepth ?? this.orbitLandscapeDepth, + orbitLandscapeVisibleItems: + orbitLandscapeVisibleItems ?? this.orbitLandscapeVisibleItems, + orbitLandscapeCardWidthRatio: + orbitLandscapeCardWidthRatio ?? this.orbitLandscapeCardWidthRatio, streaksEnabled: streaksEnabled ?? this.streaksEnabled, showMoreFromArtist: showMoreFromArtist ?? this.showMoreFromArtist, showMoreArtists: showMoreArtists ?? this.showMoreArtists, @@ -609,6 +637,7 @@ class AppPreferencesService { static const _animatedAlbumArtKey = 'album_animated_art'; static const _folderGridPageSizeKey = 'folder_grid_page_size'; static const _lastSeenChangelogVersionKey = 'last_seen_changelog_version'; + /// UI language. A locale tag such as 'zh_CN', or 'system' to follow the device. static const _appLocaleKey = 'app_locale'; static const _baybayinHeadingsKey = 'baybayin_headings'; @@ -656,6 +685,16 @@ class AppPreferencesService { 'orbit_art_resolution_multiplier'; static const _orbitShowPathKey = 'orbit_show_path'; static const _orbitShowGlowKey = 'orbit_show_glow'; + static const _orbitLandscapeRadiusRatioKey = 'orbit_landscape_radius_ratio'; + static const _orbitLandscapeCenterYRatioKey = + 'orbit_landscape_center_y_ratio'; + static const _orbitLandscapeItemSpacingKey = 'orbit_landscape_item_spacing'; + static const _orbitLandscapeSelectedScaleKey = + 'orbit_landscape_selected_scale'; + static const _orbitLandscapeDepthKey = 'orbit_landscape_depth'; + static const _orbitLandscapeVisibleItemsKey = 'orbit_landscape_visible_items'; + static const _orbitLandscapeCardWidthRatioKey = + 'orbit_landscape_card_width_ratio'; static const _streaksEnabledKey = 'streaks_enabled'; static const _showMoreFromArtistKey = 'album_show_more_from_artist'; static const _showMoreArtistsKey = 'album_show_more_artists'; @@ -743,14 +782,12 @@ class AppPreferencesService { prefs.getString(_widgetFlagshipAccentKey) ?? 'white', widgetFlagshipShowArtist: prefs.getBool(_widgetFlagshipShowArtistKey) ?? true, - widgetCompactBgOpacity: - prefs.getInt(_widgetCompactBgOpacityKey) ?? 3, + widgetCompactBgOpacity: prefs.getInt(_widgetCompactBgOpacityKey) ?? 3, widgetCompactShowAlbumArt: prefs.getBool(_widgetCompactShowAlbumArtKey) ?? true, widgetCompactShowArtist: prefs.getBool(_widgetCompactShowArtistKey) ?? true, - widgetCompactAccent: - prefs.getString(_widgetCompactAccentKey) ?? 'white', + widgetCompactAccent: prefs.getString(_widgetCompactAccentKey) ?? 'white', widgetTextScale: prefs.getDouble(_widgetTextScaleKey) ?? 1.0, widgetFlagshipTextScale: prefs.getDouble(_widgetFlagshipTextScaleKey) ?? 1.0, @@ -761,17 +798,14 @@ class AppPreferencesService { prefs.getBool(_lyricsMatchAudioFilenameKey) ?? false, leftActionButton: prefs.getString(_leftActionButtonKey) ?? 'lyrics', rightActionButton: prefs.getString(_rightActionButtonKey) ?? 'favorites', - leftTopActionButton: - prefs.getString(_leftTopActionButtonKey) ?? 'none', - rightTopActionButton: - prefs.getString(_rightTopActionButtonKey) ?? 'none', + leftTopActionButton: prefs.getString(_leftTopActionButtonKey) ?? 'none', + rightTopActionButton: prefs.getString(_rightTopActionButtonKey) ?? 'none', welcomeCardDismissed: prefs.getBool(_welcomeCardDismissedKey) ?? false, glanceCardHidden: prefs.getBool(_glanceCardHiddenKey) ?? false, glanceCardMinimized: prefs.getBool(_glanceCardMinimizedKey) ?? false, replaceAlbumWithBitPerfectCapsule: prefs.getBool(_replaceAlbumWithBitPerfectCapsuleKey) ?? false, - albumsStretchArtwork: - prefs.getBool(_albumsStretchArtworkKey) ?? false, + albumsStretchArtwork: prefs.getBool(_albumsStretchArtworkKey) ?? false, animatedAlbumArt: prefs.getBool(_animatedAlbumArtKey) ?? true, folderGridPageSize: prefs.getInt(_folderGridPageSizeKey) ?? 8, lastSeenChangelogVersion: prefs.getString(_lastSeenChangelogVersionKey), @@ -784,8 +818,7 @@ class AppPreferencesService { separateMiniPlayerFromNavBar: prefs.getBool(_separateMiniPlayerFromNavBarKey) ?? false, keepPlayingOnQuit: prefs.getBool(_keepPlayingOnQuitKey) ?? false, - priorityAnchorEnabled: - prefs.getBool(_priorityAnchorEnabledKey) ?? true, + priorityAnchorEnabled: prefs.getBool(_priorityAnchorEnabledKey) ?? true, motionArtDuringBitPerfect: prefs.getBool(_motionArtDuringBitPerfectKey) ?? false, pauseOnBluetoothDisconnect: @@ -796,25 +829,19 @@ class AppPreferencesService { prefs.getBool(_pauseOnUsbDacDisconnectKey) ?? true, pauseOnBluetoothConnect: prefs.getBool(_pauseOnBluetoothConnectKey) ?? false, - pauseOnUsbDacConnect: - prefs.getBool(_pauseOnUsbDacConnectKey) ?? false, + pauseOnUsbDacConnect: prefs.getBool(_pauseOnUsbDacConnectKey) ?? false, preferredBluetoothDevice: prefs.getString(_preferredBluetoothDeviceKey) ?? '', btPreferredCodec: prefs.getInt(_btPreferredCodecKey) ?? -1, btLdacBitrate: prefs.getString(_btLdacBitrateKey) ?? 'adaptive', - btAbsoluteVolumeSync: - prefs.getBool(_btAbsoluteVolumeSyncKey) ?? false, - btEnableCodecControl: - prefs.getBool(_btEnableCodecControlKey) ?? false, + btAbsoluteVolumeSync: prefs.getBool(_btAbsoluteVolumeSyncKey) ?? false, + btEnableCodecControl: prefs.getBool(_btEnableCodecControlKey) ?? false, btSampleRate: prefs.getInt(_btSampleRateKey) ?? 0, btLdacBitsPerSample: prefs.getInt(_btLdacBitsPerSampleKey) ?? 0, - floatingPlayerEnabled: - prefs.getBool(_floatingPlayerEnabledKey) ?? false, - floatingIslandEnabled: - prefs.getBool(_floatingIslandEnabledKey) ?? false, + floatingPlayerEnabled: prefs.getBool(_floatingPlayerEnabledKey) ?? false, + floatingIslandEnabled: prefs.getBool(_floatingIslandEnabledKey) ?? false, autoFocusSearch: prefs.getBool(_autoFocusSearchKey) ?? false, - searchPlaybackMode: - prefs.getString(_searchPlaybackModeKey) ?? 'results', + searchPlaybackMode: prefs.getString(_searchPlaybackModeKey) ?? 'results', refreshRateMode: await _resolveRefreshRateMode(prefs), visualizerEnabled: prefs.getBool(_visualizerEnabledKey) ?? true, orbitRadiusRatio: prefs.getDouble(_orbitRadiusRatioKey) ?? 1.0, @@ -831,6 +858,19 @@ class AppPreferencesService { prefs.getDouble(_orbitArtResolutionMultiplierKey) ?? 2.0, orbitShowPath: prefs.getBool(_orbitShowPathKey) ?? true, orbitShowGlow: prefs.getBool(_orbitShowGlowKey) ?? true, + orbitLandscapeRadiusRatio: + prefs.getDouble(_orbitLandscapeRadiusRatioKey) ?? 1.0, + orbitLandscapeCenterYRatio: + prefs.getDouble(_orbitLandscapeCenterYRatioKey) ?? 0.48, + orbitLandscapeItemSpacing: + prefs.getDouble(_orbitLandscapeItemSpacingKey) ?? 0.28, + orbitLandscapeSelectedScale: + prefs.getDouble(_orbitLandscapeSelectedScaleKey) ?? 1.25, + orbitLandscapeDepth: prefs.getDouble(_orbitLandscapeDepthKey) ?? 0.75, + orbitLandscapeVisibleItems: + prefs.getInt(_orbitLandscapeVisibleItemsKey) ?? 5, + orbitLandscapeCardWidthRatio: + prefs.getDouble(_orbitLandscapeCardWidthRatioKey) ?? 0.68, streaksEnabled: prefs.getBool(_streaksEnabledKey) ?? true, showMoreFromArtist: prefs.getBool(_showMoreFromArtistKey) ?? true, showMoreArtists: prefs.getBool(_showMoreArtistsKey) ?? true, @@ -840,8 +880,7 @@ class AppPreferencesService { prefs.getBool(_detailHeaderCenteredTitleKey) ?? false, songTileThumbnailMode: prefs.getString(_songTileThumbnailModeKey) ?? 'artwork', - extendedVolumeEnabled: - prefs.getBool(_extendedVolumeEnabledKey) ?? false, + extendedVolumeEnabled: prefs.getBool(_extendedVolumeEnabledKey) ?? false, replayGainMode: prefs.getString(_replayGainModeKey) ?? 'off', lyricsTextAlign: prefs.getString(_lyricsTextAlignKey) ?? 'center', karaokeEnabled: prefs.getBool(_lyricsKaraokeEnabledKey) ?? true, @@ -1939,6 +1978,41 @@ class AppPreferencesService { await prefs.setBool(_orbitShowGlowKey, value); } + Future setOrbitLandscapeRadiusRatio(double value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_orbitLandscapeRadiusRatioKey, value); + } + + Future setOrbitLandscapeCenterYRatio(double value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_orbitLandscapeCenterYRatioKey, value); + } + + Future setOrbitLandscapeItemSpacing(double value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_orbitLandscapeItemSpacingKey, value); + } + + Future setOrbitLandscapeSelectedScale(double value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_orbitLandscapeSelectedScaleKey, value); + } + + Future setOrbitLandscapeDepth(double value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_orbitLandscapeDepthKey, value); + } + + Future setOrbitLandscapeVisibleItems(int value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_orbitLandscapeVisibleItemsKey, value); + } + + Future setOrbitLandscapeCardWidthRatio(double value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_orbitLandscapeCardWidthRatioKey, value); + } + Future getStreaksEnabled() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool(_streaksEnabledKey) ?? true; @@ -1999,7 +2073,7 @@ class AppPreferencesService { await prefs.setString(_songTileThumbnailModeKey, value); } - Future clearOrbitSettings() async { + Future clearOrbitPortraitLayout() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_orbitRadiusRatioKey); await prefs.remove(_orbitCenterOffsetRatioKey); @@ -2010,6 +2084,23 @@ class AppPreferencesService { await prefs.remove(_orbitVisibleItemsKey); await prefs.remove(_orbitCardArtSizeKey); await prefs.remove(_orbitCardWidthRatioKey); + } + + Future clearOrbitLandscapeLayout() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_orbitLandscapeRadiusRatioKey); + await prefs.remove(_orbitLandscapeCenterYRatioKey); + await prefs.remove(_orbitLandscapeItemSpacingKey); + await prefs.remove(_orbitLandscapeSelectedScaleKey); + await prefs.remove(_orbitLandscapeDepthKey); + await prefs.remove(_orbitLandscapeVisibleItemsKey); + await prefs.remove(_orbitLandscapeCardWidthRatioKey); + } + + Future clearOrbitSettings() async { + await clearOrbitPortraitLayout(); + await clearOrbitLandscapeLayout(); + final prefs = await SharedPreferences.getInstance(); await prefs.remove(_orbitArtResolutionMultiplierKey); await prefs.remove(_orbitShowPathKey); await prefs.remove(_orbitShowGlowKey); From 539289403e98c3da3df725eefeb3e4509a01de75 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Fri, 6 Nov 2026 18:58:49 +0800 Subject: [PATCH 04/11] Add landscape orbit layout widget tests Cover card count, depth, selected scale, radius, spacing, width, and focal position from the landscape profile, and verify the selected card stays within the stage at extreme sizes and scales. --- test/features/songs/orbit_scroll_test.dart | 192 +++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/test/features/songs/orbit_scroll_test.dart b/test/features/songs/orbit_scroll_test.dart index fe621fc3..f53417c1 100644 --- a/test/features/songs/orbit_scroll_test.dart +++ b/test/features/songs/orbit_scroll_test.dart @@ -32,6 +32,13 @@ void main() { ValueChanged? onChanged, ValueChanged? onAction, double textScale = 1, + double radiusRatio = 1.0, + double centerYRatio = 0.42, + double itemSpacing = 0.28, + double selectedScale = 1.25, + double depth = 0.75, + int visibleItems = 5, + double cardWidthRatio = 0.68, }) => MaterialApp( builder: (context, child) => MediaQuery( data: MediaQuery.of( @@ -58,6 +65,13 @@ void main() { scrollDirection: axis, controller: orbit, swipeActionsEnabled: true, + radiusRatio: radiusRatio, + centerYRatio: centerYRatio, + itemSpacing: itemSpacing, + selectedScale: selectedScale, + depth: depth, + visibleItems: visibleItems, + cardWidthRatio: cardWidthRatio, onSelectedIndexChanged: onChanged, onSongSwipedLeft: onAction, onSongSwipedRight: onAction, @@ -207,4 +221,182 @@ void main() { await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); + + testWidgets('landscape settings drive card count, depth and selected scale', ( + tester, + ) async { + setView(tester, const Size(900, 411)); + final pages = PageController(); + addTearDown(pages.dispose); + + Future pump({ + double selectedScale = 1.25, + double depth = 0.75, + int visibleItems = 5, + }) async { + await tester.pumpWidget( + host( + pages, + selectedScale: selectedScale, + depth: depth, + visibleItems: visibleItems, + ), + ); + await tester.pumpAndSettle(); + } + + SongCard selectedCard() => tester.widget( + find.byWidgetPredicate( + (widget) => widget is SongCard && widget.isSelected, + ), + ); + SongCard card(int index) => tester.widget( + find.byWidgetPredicate( + (widget) => widget is SongCard && widget.song.id == 'orbit-$index', + ), + ); + + await pump(); + expect(find.byType(SongCard), findsNWidgets(5)); + expect(selectedCard().scale, closeTo(1.25, 0.001)); + expect(card(16).scale, closeTo(1.0 - 0.6 * 0.75, 0.001)); + + await pump(depth: 0.2); + expect(card(16).scale, closeTo(1.0 - 0.6 * 0.2, 0.001)); + + await pump(selectedScale: 1.8); + expect(selectedCard().scale, closeTo(1.8, 0.001)); + + await pump(visibleItems: 7); + expect(find.byType(SongCard), findsNWidgets(7)); + expect(tester.takeException(), isNull); + }); + + testWidgets('landscape radius, spacing and width feed the renderer', ( + tester, + ) async { + setView(tester, const Size(900, 411)); + final pages = PageController(); + addTearDown(pages.dispose); + final selected = find.byWidgetPredicate( + (widget) => widget is SongCard && widget.isSelected, + ); + final neighbor = find.byWidgetPredicate( + (widget) => widget is SongCard && widget.song.id == 'orbit-16', + ); + + Future pump({ + double radiusRatio = 1.0, + double itemSpacing = 0.28, + double cardWidthRatio = 0.68, + }) async { + await tester.pumpWidget( + host( + pages, + radiusRatio: radiusRatio, + itemSpacing: itemSpacing, + cardWidthRatio: cardWidthRatio, + ), + ); + await tester.pumpAndSettle(); + } + + double neighborOffset() => + (tester.getCenter(neighbor).dx - tester.getCenter(selected).dx).abs(); + + await pump(); + final baseOffset = neighborOffset(); + final baseWidth = tester.widget(selected).width!; + + await pump(radiusRatio: 1.4); + expect(neighborOffset(), greaterThan(baseOffset * 1.3)); + + await pump(itemSpacing: 0.5); + expect(neighborOffset(), greaterThan(baseOffset * 1.5)); + + await pump(cardWidthRatio: 0.5); + expect(tester.widget(selected).width!, lessThan(baseWidth)); + expect(tester.takeException(), isNull); + }); + + testWidgets('landscape focal position follows the profile value', ( + tester, + ) async { + setView(tester, const Size(900, 411)); + final pages = PageController(); + addTearDown(pages.dispose); + final selected = find.byWidgetPredicate( + (widget) => widget is SongCard && widget.isSelected, + ); + + await tester.pumpWidget(host(pages, centerYRatio: 0.3, selectedScale: 1.0)); + await tester.pumpAndSettle(); + final high = tester.getCenter(selected).dy; + + await tester.pumpWidget(host(pages, centerYRatio: 0.6, selectedScale: 1.0)); + await tester.pumpAndSettle(); + final low = tester.getCenter(selected).dy; + + expect(low, greaterThan(high + 20)); + expect(tester.takeException(), isNull); + }); + + testWidgets('selected landscape card stays inside the stage at extremes', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final pages = PageController(); + addTearDown(pages.dispose); + final orbit = find.byKey(const ValueKey('orbit')); + final selected = find.byWidgetPredicate( + (widget) => widget is SongCard && widget.isSelected, + ); + + for (final size in const [Size(900, 411), Size(640, 240)]) { + for (final centerYRatio in const [0.3, 0.6]) { + for (final selectedScale in const [1.0, 1.8]) { + tester.view.physicalSize = size; + await tester.pumpWidget( + host( + pages, + centerYRatio: centerYRatio, + selectedScale: selectedScale, + radiusRatio: 2.0, + cardWidthRatio: 0.85, + visibleItems: 7, + textScale: 2, + ), + ); + await tester.pumpAndSettle(); + + final stage = tester.getRect(orbit); + final card = tester.getRect(selected); + final reason = '$size centerY=$centerYRatio scale=$selectedScale'; + expect( + card.top, + greaterThanOrEqualTo(stage.top - 0.5), + reason: reason, + ); + expect( + card.bottom, + lessThanOrEqualTo(stage.bottom + 0.5), + reason: reason, + ); + expect( + card.left, + greaterThanOrEqualTo(stage.left - 0.5), + reason: reason, + ); + expect( + card.right, + lessThanOrEqualTo(stage.right + 0.5), + reason: reason, + ); + expect(tester.takeException(), isNull, reason: reason); + } + } + } + }); } From 3ccad920999fd4c92f637c0b217e5775bcf68740 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Fri, 6 Nov 2026 19:02:29 +0800 Subject: [PATCH 05/11] Wire landscape orbit preferences into songs view Use the landscape orbit geometry, sizing, and depth values when the viewport is wide, and expose the layout profile in settings search. --- .../data/settings_search_catalog.dart | 4 +++ lib/features/songs/screens/songs_screen.dart | 32 ++++++++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/lib/features/settings/data/settings_search_catalog.dart b/lib/features/settings/data/settings_search_catalog.dart index 0002b099..b0cf5d8a 100644 --- a/lib/features/settings/data/settings_search_catalog.dart +++ b/lib/features/settings/data/settings_search_catalog.dart @@ -572,6 +572,10 @@ List buildSettingsSearchCatalog() { LucideIcons.orbit, (id) => OrbitSettingsScreen(initialSection: id), sections: [ + _section('orbitLayoutProfile', l10n.orbitLayoutProfile, [ + ('portrait', l10n.portrait), + ('landscape', l10n.landscape), + ]), _section('geometry', l10n.geometry, [ ('curvature', l10n.curvature), ('verticalPosition', l10n.verticalPosition), diff --git a/lib/features/songs/screens/songs_screen.dart b/lib/features/songs/screens/songs_screen.dart index 778956ef..8389c19e 100644 --- a/lib/features/songs/screens/songs_screen.dart +++ b/lib/features/songs/screens/songs_screen.dart @@ -432,6 +432,7 @@ class _SongsScreenState extends ConsumerState Widget _buildOrbitView(List songs, bool swipeActionsEnabled) { final orbit = ref.watch(appPreferencesProvider); + final landscape = AdaptiveLayout.isWide(MediaQuery.sizeOf(context)); return GestureDetector( onLongPress: () { if (_selectionMode) return; @@ -444,25 +445,34 @@ class _SongsScreenState extends ConsumerState } }, child: OrbitScroll( - scrollDirection: - MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height - ? Axis.horizontal - : Axis.vertical, + scrollDirection: landscape ? Axis.horizontal : Axis.vertical, controller: _orbitScrollController, songs: songs, selectedIndex: _selectedIndex.clamp(0, songs.length - 1).toInt(), swipeActionsEnabled: swipeActionsEnabled && !_selectionMode, isSelectionMode: _selectionMode, selectedIds: _selectedIds, - radiusRatio: orbit.orbitRadiusRatio, + radiusRatio: landscape + ? orbit.orbitLandscapeRadiusRatio + : orbit.orbitRadiusRatio, centerOffsetRatio: orbit.orbitCenterOffsetRatio, - centerYRatio: orbit.orbitCenterYRatio, - itemSpacing: orbit.orbitItemSpacing, - selectedScale: orbit.orbitSelectedScale, - depth: orbit.orbitDepth, - visibleItems: orbit.orbitVisibleItems, + centerYRatio: landscape + ? orbit.orbitLandscapeCenterYRatio + : orbit.orbitCenterYRatio, + itemSpacing: landscape + ? orbit.orbitLandscapeItemSpacing + : orbit.orbitItemSpacing, + selectedScale: landscape + ? orbit.orbitLandscapeSelectedScale + : orbit.orbitSelectedScale, + depth: landscape ? orbit.orbitLandscapeDepth : orbit.orbitDepth, + visibleItems: landscape + ? orbit.orbitLandscapeVisibleItems + : orbit.orbitVisibleItems, cardArtSize: orbit.orbitCardArtSize, - cardWidthRatio: orbit.orbitCardWidthRatio, + cardWidthRatio: landscape + ? orbit.orbitLandscapeCardWidthRatio + : orbit.orbitCardWidthRatio, artResolutionMultiplier: orbit.orbitArtResolutionMultiplier, showPath: orbit.orbitShowPath, showGlow: orbit.orbitShowGlow, From 351ff179adf1427dd36fe30ecec4263383df3fb7 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Fri, 6 Nov 2026 19:02:34 +0800 Subject: [PATCH 06/11] Add per-profile orbit settings localization strings Replace the generic orbital reset strings with profile-specific portrait/landscape labels, hints, reset confirmations, and shared visuals section headers. --- lib/l10n/app_en.arb | 40 ++++++++++++--- lib/l10n/app_tl.arb | 11 +++- lib/l10n/app_zh.arb | 11 +++- lib/l10n/generated/app_localizations.dart | 54 +++++++++++++++++--- lib/l10n/generated/app_localizations_en.dart | 29 +++++++++-- lib/l10n/generated/app_localizations_tl.dart | 29 +++++++++-- lib/l10n/generated/app_localizations_zh.dart | 25 ++++++++- 7 files changed, 175 insertions(+), 24 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b2b580a1..34c1f36b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -5542,6 +5542,14 @@ "@labelOrganization": { "description": "lib/features/songs/widgets/song_actions_bottom_sheet.dart:699" }, + "landscape": "Landscape", + "@landscape": { + "description": "Orbit settings profile for windows that are wider than they are tall." + }, + "landscapeLayoutReset": "Landscape layout reset", + "@landscapeLayoutReset": { + "description": "Confirmation shown after resetting the landscape orbit layout." + }, "language": "Language", "@language": { "description": "Settings entry label and the title of the language picker." @@ -7159,9 +7167,21 @@ "@orbital": { "description": "lib/models/song_view_mode.dart:17" }, - "orbitalSettingsReset": "Orbital settings reset", - "@orbitalSettingsReset": { - "description": "lib/features/settings/screens/orbit_settings_screen.dart:205" + "orbitLandscapeHint": "Used when the app window is wider than it is tall.", + "@orbitLandscapeHint": { + "description": "Explains when the landscape orbit profile is applied." + }, + "orbitLayoutProfile": "Layout Profile", + "@orbitLayoutProfile": { + "description": "Header for choosing the portrait or landscape orbit profile." + }, + "orbitPortraitHint": "Used when the app window is taller than it is wide.", + "@orbitPortraitHint": { + "description": "Explains when the portrait orbit profile is applied." + }, + "orbitSharedVisuals": "Shared Visuals", + "@orbitSharedVisuals": { + "description": "Header for orbit visuals shared by both orientation profiles." }, "otherPlaylists": "Other Playlists", "@otherPlaylists": { @@ -7620,6 +7640,14 @@ "@pleaseEnterYourListenbrainzUserToken": { "description": "lib/features/settings/widgets/listenbrainz_settings_tile.dart:584" }, + "portrait": "Portrait", + "@portrait": { + "description": "Orbit settings profile for windows that are taller than they are wide." + }, + "portraitLayoutReset": "Portrait layout reset", + "@portraitLayoutReset": { + "description": "Confirmation shown after resetting the portrait orbit layout." + }, "posterPreviewPlays": "{arg1} poster preview. {arg2} plays.", "@posterPreviewPlays": { "description": "lib/features/recap/screens/replay_studio_screen.dart:423", @@ -8360,9 +8388,9 @@ "@restoreDefaultProgressBarSize": { "description": "lib/features/settings/screens/ui_customization_settings_screen.dart" }, - "restoreTheOriginalOrbitalLayout": "Restore the original orbital layout", - "@restoreTheOriginalOrbitalLayout": { - "description": "lib/features/settings/screens/orbit_settings_screen.dart:200" + "restoreThisProfilesOrbitLayout": "Restore this profile's original orbit layout", + "@restoreThisProfilesOrbitLayout": { + "description": "Subtitle for resetting only the selected orbit profile." }, "results": "Results ({arg1})", "@results": { diff --git a/lib/l10n/app_tl.arb b/lib/l10n/app_tl.arb index 52e37679..972f210d 100644 --- a/lib/l10n/app_tl.arb +++ b/lib/l10n/app_tl.arb @@ -1039,6 +1039,8 @@ "koFi": "Ko-fi", "label": "Label", "labelOrganization": "Label / Organisasyon", + "landscape": "Landscape", + "landscapeLayoutReset": "Na-reset ang landscape layout", "language": "Wika", "languageSectionDescription": "Piliin ang wikang gagamitin sa aplikasyon.", "languageSystemDefault": "Wika ng aparato", @@ -1386,7 +1388,10 @@ "openslEsLegacy": "OpenSL ES (luma)", "orRemoveAllUsingRecommendedPicks": "O alisin lahat gamit ang mga inirekomendang piliin", "orbital": "Orbital", - "orbitalSettingsReset": "Na-reset ang mga pagsasaayos ng Orbital", + "orbitLandscapeHint": "Ginagamit kapag mas malapad kaysa matangkad ang window ng app.", + "orbitLayoutProfile": "Profile ng layout", + "orbitPortraitHint": "Ginagamit kapag mas matangkad kaysa lapad ang window ng app.", + "orbitSharedVisuals": "Mga shared na visual", "otherPlaylists": "Iba pang talaan ng kanta", "ourRustPoweredEngineCleanerSound": "Ang aming engine na pinapagana ng Rust. Mas malinaw na tunog at mas makinis na pagtugtog — inirerekomenda sa araw-araw na pakikinig.", "outlineTheMiniPlayer": "Lagyan ng guhit sa gilid ang mini player", @@ -1492,6 +1497,8 @@ "playsInRotation": "pagtugtog sa pag-ikot", "pleaseEnterAPlaylistName": "Maglagay ng pangalan ng talaan ng kanta", "pleaseEnterYourListenbrainzUserToken": "Ilagay ang iyong ListenBrainz user token.", + "portrait": "Portrait", + "portraitLayoutReset": "Na-reset ang portrait layout", "posterPreviewPlays": "Paunang tingin ng poster ng {arg1}. {arg2} pagtugtog.", "preAmp": "Pre-amp", "preamp": "Preamp", @@ -1644,7 +1651,7 @@ "restartYourDeviceToApplyOutput": "Simulan muli ang aparato upang mailapat ang mga pagbabago sa format ng output.", "restartingFlick": "Muling sinisimulan ang Flick", "restoreDefaultProgressBarSize": "Restore the selected progress bar style to its default size.", - "restoreTheOriginalOrbitalLayout": "Ibalik ang orihinal na ayos ng Orbital", + "restoreThisProfilesOrbitLayout": "Ibalik ang orihinal na orbit layout ng profile na ito", "results": "Mga resulta ({arg1})", "resumeAFamiliarFavoriteOrShuffle": "Balikan ang pamilyar na paborito o ihalo ang isa sa mga halong binuo mula sa iyong pakikinig.", "resumeOnReconnect": "Ituloy kapag muling nakakonekta", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 706ffb76..ef82a72f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1191,6 +1191,8 @@ "koFi": "Ko-fi", "label": "标签", "labelOrganization": "厂牌 / 组织", + "landscape": "横屏", + "landscapeLayoutReset": "横屏布局已重置", "language": "语言", "languageSectionDescription": "选择应用界面使用的语言。", "languageSystemDefault": "跟随系统", @@ -1539,7 +1541,10 @@ "openslEsLegacy": "OpenSL ES(旧版)", "orRemoveAllUsingRecommendedPicks": "或使用推荐选择移除全部", "orbital": "Orbital", - "orbitalSettingsReset": "轨道设置已重置", + "orbitLandscapeHint": "当应用窗口宽大于高时使用。", + "orbitLayoutProfile": "布局配置", + "orbitPortraitHint": "当应用窗口高大于宽时使用。", + "orbitSharedVisuals": "共享视觉效果", "otherPlaylists": "其他播放列表", "ourRustPoweredEngineCleanerSound": "我们基于 Rust 的引擎。声音更干净、播放更流畅,推荐日常聆听。", "outlineTheMiniPlayer": "为迷你播放器描边", @@ -1643,6 +1648,8 @@ "playsInRotation": "循环播放次数", "pleaseEnterAPlaylistName": "请输入播放列表名称", "pleaseEnterYourListenbrainzUserToken": "请输入你的 ListenBrainz 用户令牌。", + "portrait": "竖屏", + "portraitLayoutReset": "竖屏布局已重置", "posterPreviewPlays": "{arg1} 海报预览。{arg2} 次播放。", "preAmp": "前置放大", "preamp": "前置放大", @@ -1795,7 +1802,7 @@ "restartYourDeviceToApplyOutput": "重启设备以应用输出格式更改。", "restartingFlick": "正在重启 Flick", "restoreDefaultProgressBarSize": "将所选进度条样式恢复为默认大小。", - "restoreTheOriginalOrbitalLayout": "恢复原始轨道布局", + "restoreThisProfilesOrbitLayout": "恢复此配置的原始轨道布局", "results": "结果({arg1})", "resumeAFamiliarFavoriteOrShuffle": "继续播放熟悉的收藏,或随机播放由你的收听生成的混音。", "resumeOnReconnect": "重新连接时继续", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 98d1a9a2..fdf2731e 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -7199,6 +7199,18 @@ abstract class AppLocalizations { /// **'Label / Organization'** String get labelOrganization; + /// Orbit settings profile for windows that are wider than they are tall. + /// + /// In en, this message translates to: + /// **'Landscape'** + String get landscape; + + /// Confirmation shown after resetting the landscape orbit layout. + /// + /// In en, this message translates to: + /// **'Landscape layout reset'** + String get landscapeLayoutReset; + /// Settings entry label and the title of the language picker. /// /// In en, this message translates to: @@ -9286,11 +9298,29 @@ abstract class AppLocalizations { /// **'Orbital'** String get orbital; - /// lib/features/settings/screens/orbit_settings_screen.dart:205 + /// Explains when the landscape orbit profile is applied. /// /// In en, this message translates to: - /// **'Orbital settings reset'** - String get orbitalSettingsReset; + /// **'Used when the app window is wider than it is tall.'** + String get orbitLandscapeHint; + + /// Header for choosing the portrait or landscape orbit profile. + /// + /// In en, this message translates to: + /// **'Layout Profile'** + String get orbitLayoutProfile; + + /// Explains when the portrait orbit profile is applied. + /// + /// In en, this message translates to: + /// **'Used when the app window is taller than it is wide.'** + String get orbitPortraitHint; + + /// Header for orbit visuals shared by both orientation profiles. + /// + /// In en, this message translates to: + /// **'Shared Visuals'** + String get orbitSharedVisuals; /// lib/features/playlists/screens/playlist_detail_screen.dart:604 /// @@ -9910,6 +9940,18 @@ abstract class AppLocalizations { /// **'Please enter your ListenBrainz user token.'** String get pleaseEnterYourListenbrainzUserToken; + /// Orbit settings profile for windows that are taller than they are wide. + /// + /// In en, this message translates to: + /// **'Portrait'** + String get portrait; + + /// Confirmation shown after resetting the portrait orbit layout. + /// + /// In en, this message translates to: + /// **'Portrait layout reset'** + String get portraitLayoutReset; + /// lib/features/recap/screens/replay_studio_screen.dart:423 /// /// In en, this message translates to: @@ -10822,11 +10864,11 @@ abstract class AppLocalizations { /// **'Restore the selected progress bar style to its default size.'** String get restoreDefaultProgressBarSize; - /// lib/features/settings/screens/orbit_settings_screen.dart:200 + /// Subtitle for resetting only the selected orbit profile. /// /// In en, this message translates to: - /// **'Restore the original orbital layout'** - String get restoreTheOriginalOrbitalLayout; + /// **'Restore this profile\'s original orbit layout'** + String get restoreThisProfilesOrbitLayout; /// lib/features/artists/screens/artists_screen.dart:510 /// diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index e38e5aeb..b5209e94 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -4230,6 +4230,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get labelOrganization => 'Label / Organization'; + @override + String get landscape => 'Landscape'; + + @override + String get landscapeLayoutReset => 'Landscape layout reset'; + @override String get language => 'Language'; @@ -5427,7 +5433,18 @@ class AppLocalizationsEn extends AppLocalizations { String get orbital => 'Orbital'; @override - String get orbitalSettingsReset => 'Orbital settings reset'; + String get orbitLandscapeHint => + 'Used when the app window is wider than it is tall.'; + + @override + String get orbitLayoutProfile => 'Layout Profile'; + + @override + String get orbitPortraitHint => + 'Used when the app window is taller than it is wide.'; + + @override + String get orbitSharedVisuals => 'Shared Visuals'; @override String get otherPlaylists => 'Other Playlists'; @@ -5812,6 +5829,12 @@ class AppLocalizationsEn extends AppLocalizations { String get pleaseEnterYourListenbrainzUserToken => 'Please enter your ListenBrainz user token.'; + @override + String get portrait => 'Portrait'; + + @override + String get portraitLayoutReset => 'Portrait layout reset'; + @override String posterPreviewPlays(Object arg1, Object arg2) { return '$arg1 poster preview. $arg2 plays.'; @@ -6342,8 +6365,8 @@ class AppLocalizationsEn extends AppLocalizations { 'Restore the selected progress bar style to its default size.'; @override - String get restoreTheOriginalOrbitalLayout => - 'Restore the original orbital layout'; + String get restoreThisProfilesOrbitLayout => + 'Restore this profile\'s original orbit layout'; @override String results(Object arg1) { diff --git a/lib/l10n/generated/app_localizations_tl.dart b/lib/l10n/generated/app_localizations_tl.dart index df08a03c..a79078d2 100644 --- a/lib/l10n/generated/app_localizations_tl.dart +++ b/lib/l10n/generated/app_localizations_tl.dart @@ -4282,6 +4282,12 @@ class AppLocalizationsTl extends AppLocalizations { @override String get labelOrganization => 'Label / Organisasyon'; + @override + String get landscape => 'Landscape'; + + @override + String get landscapeLayoutReset => 'Na-reset ang landscape layout'; + @override String get language => 'Wika'; @@ -5486,7 +5492,18 @@ class AppLocalizationsTl extends AppLocalizations { String get orbital => 'Orbital'; @override - String get orbitalSettingsReset => 'Na-reset ang mga pagsasaayos ng Orbital'; + String get orbitLandscapeHint => + 'Ginagamit kapag mas malapad kaysa matangkad ang window ng app.'; + + @override + String get orbitLayoutProfile => 'Profile ng layout'; + + @override + String get orbitPortraitHint => + 'Ginagamit kapag mas matangkad kaysa lapad ang window ng app.'; + + @override + String get orbitSharedVisuals => 'Mga shared na visual'; @override String get otherPlaylists => 'Iba pang talaan ng kanta'; @@ -5876,6 +5893,12 @@ class AppLocalizationsTl extends AppLocalizations { String get pleaseEnterYourListenbrainzUserToken => 'Ilagay ang iyong ListenBrainz user token.'; + @override + String get portrait => 'Portrait'; + + @override + String get portraitLayoutReset => 'Na-reset ang portrait layout'; + @override String posterPreviewPlays(Object arg1, Object arg2) { return 'Paunang tingin ng poster ng $arg1. $arg2 pagtugtog.'; @@ -6412,8 +6435,8 @@ class AppLocalizationsTl extends AppLocalizations { 'Restore the selected progress bar style to its default size.'; @override - String get restoreTheOriginalOrbitalLayout => - 'Ibalik ang orihinal na ayos ng Orbital'; + String get restoreThisProfilesOrbitLayout => + 'Ibalik ang orihinal na orbit layout ng profile na ito'; @override String results(Object arg1) { diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index e2d85c92..5023d9c8 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -3930,6 +3930,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get labelOrganization => '厂牌 / 组织'; + @override + String get landscape => '横屏'; + + @override + String get landscapeLayoutReset => '横屏布局已重置'; + @override String get language => '语言'; @@ -5070,7 +5076,16 @@ class AppLocalizationsZh extends AppLocalizations { String get orbital => 'Orbital'; @override - String get orbitalSettingsReset => '轨道设置已重置'; + String get orbitLandscapeHint => '当应用窗口宽大于高时使用。'; + + @override + String get orbitLayoutProfile => '布局配置'; + + @override + String get orbitPortraitHint => '当应用窗口高大于宽时使用。'; + + @override + String get orbitSharedVisuals => '共享视觉效果'; @override String get otherPlaylists => '其他播放列表'; @@ -5404,6 +5419,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get pleaseEnterYourListenbrainzUserToken => '请输入你的 ListenBrainz 用户令牌。'; + @override + String get portrait => '竖屏'; + + @override + String get portraitLayoutReset => '竖屏布局已重置'; + @override String posterPreviewPlays(Object arg1, Object arg2) { return '$arg1 海报预览。$arg2 次播放。'; @@ -5911,7 +5932,7 @@ class AppLocalizationsZh extends AppLocalizations { String get restoreDefaultProgressBarSize => '将所选进度条样式恢复为默认大小。'; @override - String get restoreTheOriginalOrbitalLayout => '恢复原始轨道布局'; + String get restoreThisProfilesOrbitLayout => '恢复此配置的原始轨道布局'; @override String results(Object arg1) { From ffb8d4b69b87df5268d34015d48228654b8f4a59 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Fri, 6 Nov 2026 19:34:29 +0800 Subject: [PATCH 07/11] Restyle song card with padded glass border layout Show artwork above a scrollable one-line footer instead of collapsing to a text overlay on short stages, and use a smaller accent-aware selection indicator. --- lib/features/songs/widgets/song_card.dart | 189 ++++++++++------------ test/features/songs/song_card_test.dart | 28 ++++ 2 files changed, 115 insertions(+), 102 deletions(-) diff --git a/lib/features/songs/widgets/song_card.dart b/lib/features/songs/widgets/song_card.dart index a88a7444..7962054d 100644 --- a/lib/features/songs/widgets/song_card.dart +++ b/lib/features/songs/widgets/song_card.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flick/core/theme/app_colors.dart'; +import 'package:flick/core/theme/app_theme.dart'; import 'package:flick/core/constants/app_constants.dart'; import 'package:flick/core/utils/app_haptics.dart'; import 'package:flick/models/song.dart'; @@ -279,123 +280,107 @@ class _SongCardState extends State { Widget _buildArtworkCard(double artSize) { return Semantics( selected: widget.isSelected || widget.isMultiSelected, - child: ClipRRect( - borderRadius: BorderRadius.circular(AppConstants.radiusLg), - child: ColoredBox( + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( color: AppColors.surface, - child: LayoutBuilder( - builder: (context, constraints) { - final enlargedText = - MediaQuery.textScalerOf(context).scale(14) > 19; - final showArtwork = - constraints.maxHeight > (enlargedText ? 150 : 100); - final text = Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + borderRadius: BorderRadius.circular(AppConstants.radiusLg), + border: Border.all(color: AppColors.glassBorder), + ), + padding: const EdgeInsets.all(8), + child: LayoutBuilder( + builder: (context, constraints) { + // Short stages keep the artwork instead of collapsing to a text + // overlay. The footer stays one line per field and scrolls only + // when enlarged text needs more room than the card can give it. + final footerMaxHeight = constraints.maxHeight * 0.45; + return Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - widget.song.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular( + AppConstants.radiusMd, + ), + child: _buildRawImage( + widget.song.albumArt ?? '', + audioSourcePath: widget.song.filePath, + artSize: math.max( + artSize, + constraints.maxWidth, + ), + ), ), ), - const SizedBox(height: 4), - Text( - widget.song.artist, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 12, - color: AppColors.textSecondary, + const SizedBox(height: 10), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: footerMaxHeight), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 4), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.song.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'GoogleSansFlex', + fontFamilyFallback: AppTheme.fontFamilyFallback, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.15, + letterSpacing: -0.1, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + widget.song.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'GoogleSansFlex', + fontFamilyFallback: AppTheme.fontFamilyFallback, + fontSize: 12, + height: 1.2, + color: AppColors.textSecondary, + ), + ), + ], + ), ), ), ], ), - ); - if (!showArtwork) { - // A short window still shows the music, not a row of text-only - // cards. Overlay the identity instead of squeezing out the art. - return Stack( - fit: StackFit.expand, - children: [ - _buildAlbumWithGradient(artSize), - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0x33000000), Color(0xE6000000)], - ), + if (widget.isSelectionMode) + Positioned( + top: 4, + right: 4, + child: Container( + width: 26, + height: 26, + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, ), - ), - Align( - alignment: Alignment.bottomLeft, - child: SingleChildScrollView(child: text), - ), - if (widget.isSelectionMode) - Positioned( - top: 8, - right: 8, - child: Icon( - widget.isMultiSelected - ? Icons.check_circle_rounded - : Icons.radio_button_unchecked_rounded, - color: AppColors.textPrimary, - ), - ), - ], - ); - } - return Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showArtwork) - Expanded( - child: _buildRawImage( - widget.song.albumArt ?? '', - audioSourcePath: widget.song.filePath, - artSize: math.max(artSize, constraints.maxWidth), - ), - ), - if (showArtwork) - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: constraints.maxHeight * 0.55, - ), - child: SingleChildScrollView(child: text), - ) - else - Expanded(child: SingleChildScrollView(child: text)), - ], - ), - if (widget.isSelectionMode) - Positioned( - top: 8, - right: 8, child: Icon( widget.isMultiSelected ? Icons.check_circle_rounded : Icons.radio_button_unchecked_rounded, - color: AppColors.textPrimary, + color: widget.isMultiSelected + ? AppColors.accent + : AppColors.textPrimary, + size: 18, ), ), - ], - ); - }, - ), + ), + ], + ); + }, ), ), ); diff --git a/test/features/songs/song_card_test.dart b/test/features/songs/song_card_test.dart index 35aa9c4b..88904859 100644 --- a/test/features/songs/song_card_test.dart +++ b/test/features/songs/song_card_test.dart @@ -109,6 +109,34 @@ void main() { }, ); + testWidgets('landscape card keeps artwork above the song footer', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SongCard( + song: _song, + width: 220, + height: 110, + artSizeBase: 70, + artworkAbove: true, + ), + ), + ), + ), + ); + + expect(find.byType(CachedImageWidget), findsOneWidget); + final art = tester.getRect(find.byType(CachedImageWidget)); + final title = tester.getRect(find.text('Original title')); + final artist = tester.getRect(find.text('Original artist')); + expect(art.bottom, lessThanOrEqualTo(title.top)); + expect(title.bottom, lessThanOrEqualTo(artist.top)); + expect(tester.takeException(), isNull); + }); + testWidgets('selection and artwork preferences refresh the retained face', ( tester, ) async { From 67c176df9533ee905c018684c7c9ef3725502d79 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Fri, 6 Nov 2026 21:42:21 +0800 Subject: [PATCH 08/11] Let orbit ribbon paint under shell overlays Keep tab pages full-height so the landscape ribbon can extend behind the mini player and navigation bar, absorbing input in the exposed strip and reserving side space for safe areas and the mini player. --- lib/app/app.dart | 148 +++++++------ lib/features/songs/screens/songs_screen.dart | 35 ++- lib/features/songs/widgets/orbit_scroll.dart | 211 ++++++++++++++----- 3 files changed, 275 insertions(+), 119 deletions(-) diff --git a/lib/app/app.dart b/lib/app/app.dart index 74866a1a..13f5dc5c 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -912,63 +912,35 @@ class _MainShellState extends ConsumerState navBarConfigProvider, ); final order = _getPageOrder(cfg); - return Padding( - padding: EdgeInsets.only( - bottom: nestedBottomInset, - ), - child: PageView( - controller: _pageController, - physics: - const ClampingScrollPhysics(), - onPageChanged: (position) { - final currentConfig = ref.read( - navBarConfigProvider, - ); - final currentOrder = - _getPageOrder( - currentConfig, - ); - if (position < 0 || - position >= - currentOrder.length) { - return; - } + return PageView( + controller: _pageController, + physics: + const ClampingScrollPhysics(), + onPageChanged: (position) { + final currentConfig = ref.read( + navBarConfigProvider, + ); + final currentOrder = + _getPageOrder(currentConfig); + if (position < 0 || + position >= + currentOrder.length) { + return; + } + if (_programmaticPageTarget == + null && + position != _lastHapticPage) { + AppHaptics.selection(); + } + _lastHapticPage = position; + final enabledCount = currentConfig + .orderedButtons + .length; + if (position >= enabledCount) { if (_programmaticPageTarget == - null && - position != - _lastHapticPage) { - AppHaptics.selection(); - } - _lastHapticPage = position; - final enabledCount = - currentConfig - .orderedButtons - .length; - if (position >= enabledCount) { - if (_programmaticPageTarget == - position) { - _programmaticPageTarget = - null; - final pageIndex = - currentOrder[position] - .pageIndex; - if (ref.read( - navigationIndexProvider, - ) != - pageIndex) { - ref - .read( - navigationIndexProvider - .notifier, - ) - .setIndex(pageIndex); - } - return; - } - if (_programmaticPageTarget != - null) { - return; - } + position) { + _programmaticPageTarget = + null; final pageIndex = currentOrder[position] .pageIndex; @@ -985,10 +957,9 @@ class _MainShellState extends ConsumerState } return; } - if (_programmaticPageTarget == - position) { - _programmaticPageTarget = - null; + if (_programmaticPageTarget != + null) { + return; } final pageIndex = currentOrder[position] @@ -1004,15 +975,43 @@ class _MainShellState extends ConsumerState ) .setIndex(pageIndex); } - }, - children: order.map((button) { - return _buildTab( + return; + } + if (_programmaticPageTarget == + position) { + _programmaticPageTarget = null; + } + final pageIndex = + currentOrder[position] + .pageIndex; + if (ref.read( + navigationIndexProvider, + ) != + pageIndex) { + ref + .read( + navigationIndexProvider + .notifier, + ) + .setIndex(pageIndex); + } + }, + children: order.map((button) { + return Padding( + padding: EdgeInsets.only( + bottom: nestedBottomInset, + ), + child: _buildTab( tabIndex: button.pageIndex, currentIndex: currentIdx, - child: _buildScreen(button), - ); - }).toList(), - ), + child: _buildScreen( + button, + pathOverflowBottom: + nestedBottomInset, + ), + ), + ); + }).toList(), ); }, ), @@ -1028,6 +1027,18 @@ class _MainShellState extends ConsumerState ), ), + // Tab pages are full-height so the orbit ribbon can continue + // behind the bottom overlays. Keep the newly exposed strip + // from paging the PageView; the bars sit above this blocker. + if (nestedBottomInset > 0) + Positioned( + left: useSideNavigation ? railWidth : 0, + right: 0, + bottom: 0, + height: nestedBottomInset, + child: const AbsorbPointer(child: SizedBox.expand()), + ), + // Wide windows use a side rail; portrait keeps the bottom bar. if (useSideNavigation) Positioned( @@ -1123,7 +1134,7 @@ class _MainShellState extends ConsumerState return [...config.orderedButtons, ...disabledEssentials]; } - Widget _buildScreen(NavBarButton button) { + Widget _buildScreen(NavBarButton button, {double pathOverflowBottom = 0}) { return switch (button) { NavBarButton.menu => MenuScreen( key: const ValueKey('menu'), @@ -1133,6 +1144,7 @@ class _MainShellState extends ConsumerState ), NavBarButton.songs => SongsScreen( key: const ValueKey('songs'), + pathOverflowBottom: pathOverflowBottom, onNavigationRequested: (index) { ref.read(navigationIndexProvider.notifier).setIndex(index); }, diff --git a/lib/features/songs/screens/songs_screen.dart b/lib/features/songs/screens/songs_screen.dart index 8389c19e..76bbe8d3 100644 --- a/lib/features/songs/screens/songs_screen.dart +++ b/lib/features/songs/screens/songs_screen.dart @@ -53,7 +53,16 @@ class SongsScreen extends ConsumerStatefulWidget { /// Callback when navigation to a different tab is requested from full player final ValueChanged? onNavigationRequested; - const SongsScreen({super.key, this.onNavigationRequested}); + /// Height of the shell's bottom overlay that the orbit ribbon may paint + /// through. Kept separate from the card stage so the cards stay clear of the + /// mini player and navigation bar. + final double pathOverflowBottom; + + const SongsScreen({ + super.key, + this.onNavigationRequested, + this.pathOverflowBottom = 0, + }); @override ConsumerState createState() => _SongsScreenState(); @@ -142,6 +151,15 @@ class _SongsScreenState extends ConsumerState AdaptiveLayout.useCompactLandscapeMiniPlayer( MediaQuery.sizeOf(context), ); + final safePadding = MediaQuery.paddingOf(context); + final orbitSideOverflow = EdgeInsets.only( + left: safePadding.left, + right: + safePadding.right + + (reserveSideMiniPlayer + ? AdaptiveLayout.sideMiniPlayerContentInset + : 0), + ); final swipeActionsEnabled = ref .watch(appPreferencesProvider) .swipeActionsEnabled; @@ -338,6 +356,7 @@ class _SongsScreenState extends ConsumerState navBarVisible, swipeActionsEnabled, appPrefs.fastIndexEnabled, + orbitSideOverflow, ); }, ), @@ -357,11 +376,12 @@ class _SongsScreenState extends ConsumerState bool isBottomBarVisible, bool swipeActionsEnabled, bool fastIndexEnabled, + EdgeInsets orbitSideOverflow, ) { final overlayRailVisible = fastIndexEnabled && _fastIndexVisible; final content = viewMode == SongViewMode.list ? _buildListView(songs, swipeActionsEnabled, overlayRailVisible) - : _buildOrbitView(songs, swipeActionsEnabled); + : _buildOrbitView(songs, swipeActionsEnabled, orbitSideOverflow); if (tokenToIndexMap.isEmpty) { return content; @@ -430,7 +450,11 @@ class _SongsScreenState extends ConsumerState ); } - Widget _buildOrbitView(List songs, bool swipeActionsEnabled) { + Widget _buildOrbitView( + List songs, + bool swipeActionsEnabled, + EdgeInsets sideOverflow, + ) { final orbit = ref.watch(appPreferencesProvider); final landscape = AdaptiveLayout.isWide(MediaQuery.sizeOf(context)); return GestureDetector( @@ -476,6 +500,11 @@ class _SongsScreenState extends ConsumerState artResolutionMultiplier: orbit.orbitArtResolutionMultiplier, showPath: orbit.orbitShowPath, showGlow: orbit.orbitShowGlow, + // The shell overlays the mini player and navigation bar on the stage. + // The ribbon may continue through that reserved strip; the card stage + // stays above it. + pathOverflowBottom: widget.pathOverflowBottom, + horizontalPaintOverflow: sideOverflow, onSelectedIndexChanged: (index) { if (!mounted) return; _showFastIndexOverlay(); diff --git a/lib/features/songs/widgets/orbit_scroll.dart b/lib/features/songs/widgets/orbit_scroll.dart index ea113d1d..77e1c9c3 100644 --- a/lib/features/songs/widgets/orbit_scroll.dart +++ b/lib/features/songs/widgets/orbit_scroll.dart @@ -75,6 +75,15 @@ class OrbitScroll extends StatefulWidget { final bool showPath; final bool showGlow; + /// Extra bottom room the path ribbon may paint through without letting the + /// card stage or gesture surface move into the bottom bars. + final double pathOverflowBottom; + + /// Side space reserved by the parent for safe areas and the mini player. + /// Landscape cards and ribbon paint through it, but gestures stay in the + /// inset music viewport so navigation and player controls keep their targets. + final EdgeInsets horizontalPaintOverflow; + const OrbitScroll({ super.key, required this.songs, @@ -100,6 +109,8 @@ class OrbitScroll extends StatefulWidget { this.artResolutionMultiplier = 2.0, this.showPath = true, this.showGlow = true, + this.pathOverflowBottom = 0.0, + this.horizontalPaintOverflow = EdgeInsets.zero, }); @override @@ -108,6 +119,8 @@ class OrbitScroll extends StatefulWidget { class _OrbitScrollState extends State with SingleTickerProviderStateMixin { + static const double _landscapeNavigationGutter = 28; + late AnimationController _controller; // The physics state — notifier drives only the song items rebuild @@ -419,8 +432,20 @@ class _OrbitScrollState extends State @override Widget build(BuildContext context) { final horizontal = widget.scrollDirection == Axis.horizontal; + final sideOverflow = horizontal + ? EdgeInsets.only( + left: + _landscapeNavigationGutter + + math.max(0.0, widget.horizontalPaintOverflow.left), + right: + _landscapeNavigationGutter + + math.max(0.0, widget.horizontalPaintOverflow.right), + ) + : EdgeInsets.zero; return Padding( - padding: EdgeInsets.symmetric(horizontal: horizontal ? 28 : 0), + padding: EdgeInsets.symmetric( + horizontal: horizontal ? _landscapeNavigationGutter : 0, + ), child: LayoutBuilder( builder: (context, constraints) { final size = constraints.biggest; @@ -472,42 +497,66 @@ class _OrbitScrollState extends State onHorizontalDragEnd: horizontal ? _onDragEnd : null, onHorizontalDragCancel: horizontal ? _onDragCancel : null, behavior: HitTestBehavior.opaque, - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.transparent, + child: SizedBox.expand( child: Stack( - clipBehavior: Clip.hardEdge, + // Paint through the side gutters, not above the stage. Only + // the ribbon also continues into the bottom-bar inset. + clipBehavior: Clip.none, children: [ - // Background glow - if (widget.showGlow) - _buildSelectionGlow( + const Positioned.fill( + child: ColoredBox(color: Colors.transparent), + ), + + // Path + if (widget.showPath) + _buildOrbitPath( orbitCenterX, orbitCenterY, orbitRadius, + sideOverflow, ), - // Path - if (widget.showPath) - _buildOrbitPath(orbitCenterX, orbitCenterY, orbitRadius), - - // Songs — only rebuilds when scroll offset changes - ValueListenableBuilder( - valueListenable: _scrollOffset, - builder: (context, _, __) { - return SizedBox.expand( - child: Stack( - clipBehavior: Clip.none, - children: _buildSongItems( - orbitCenterX, - orbitCenterY, - orbitRadius, - cardWidth: horizontal ? landscapeCardWidth : null, - cardHeight: horizontal ? landscapeCardHeight : null, + // Cards reach the viewport's side edges while keeping the + // original top/bottom limits and inset gesture surface. + Positioned.fill( + child: ClipRect( + clipper: _OrbitStageClipper(sideOverflow), + child: Stack( + clipBehavior: Clip.none, + children: [ + if (widget.showGlow) + _buildSelectionGlow( + orbitCenterX, + orbitCenterY, + orbitRadius, + ), + // Songs — only rebuilds when scroll offset changes + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: _scrollOffset, + builder: (context, _, __) { + return SizedBox.expand( + child: Stack( + clipBehavior: Clip.none, + children: _buildSongItems( + orbitCenterX, + orbitCenterY, + orbitRadius, + cardWidth: horizontal + ? landscapeCardWidth + : null, + cardHeight: horizontal + ? landscapeCardHeight + : null, + ), + ), + ); + }, + ), ), - ), - ); - }, + ], + ), + ), ), ], ), @@ -554,18 +603,33 @@ class _OrbitScrollState extends State ); } - Widget _buildOrbitPath(double centerX, double centerY, double radius) { - return RepaintBoundary( + Widget _buildOrbitPath( + double centerX, + double centerY, + double radius, + EdgeInsets sideOverflow, + ) { + final path = RepaintBoundary( child: CustomPaint( size: Size.infinite, painter: _OrbitPathPainter( - centerX: centerX, + centerX: centerX + sideOverflow.left, centerY: centerY, radius: radius, horizontal: widget.scrollDirection == Axis.horizontal, ), ), ); + final overflow = math.max(0.0, widget.pathOverflowBottom); + // Give the cached path the full paint width, not the padded gesture width. + // Its top still starts below the header/search; only its bottom overflows. + return Positioned( + left: -sideOverflow.left, + top: 0, + right: -sideOverflow.right, + bottom: -overflow, + child: ClipRect(child: path), + ); } /// Unscaled card height for a horizontal (landscape) orbit. The selected @@ -754,6 +818,26 @@ class _OrbitScrollState extends State } } +/// Expand only the horizontal paint clip, without changing card geometry or +/// hit testing in the inset music viewport. +class _OrbitStageClipper extends CustomClipper { + final EdgeInsets overflow; + + const _OrbitStageClipper(this.overflow); + + @override + Rect getClip(Size size) => Rect.fromLTRB( + -overflow.left, + 0, + size.width + overflow.right, + size.height, + ); + + @override + bool shouldReclip(covariant _OrbitStageClipper oldClipper) => + overflow != oldClipper.overflow; +} + class _Position { final double x; final double y; @@ -788,25 +872,56 @@ class _OrbitPathPainter extends CustomPainter { this.horizontal = false, }); + /// Painted once per geometry change: this painter has no animation input, so + /// scrolling never repaints the ribbon. It is a full ring rather than a fixed + /// arc, so the visible ribbon always runs out of the viewport instead of + /// stopping at a chosen angle. The inner edge starts fully transparent, which + /// removes the hard cut where the shading meets the page background. @override void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = AppColors.glassBorder.withValues(alpha: 0.15) + if (radius <= 0) return; + + final center = Offset(centerX, centerY); + + // Band width tracks the orbit so the treatment holds together from the + // compact portrait arc to the wide tablet sweep. + final bandWidth = (radius * 0.18).clamp(56.0, 120.0); + final outerRadius = radius + bandWidth / 2; + final innerRadius = math.max(0.0, radius - bandWidth / 2); + final outerRect = Rect.fromCircle(center: center, radius: outerRadius); + final innerStop = outerRadius == 0 + ? 0.0 + : (innerRadius / outerRadius).clamp(0.0, 1.0); + + // Radial, not linear: the band is a surface, so it reads brightest along + // its outer rim and dissolves to nothing toward the orbit's interior. The + // leading transparent stop is the difference between a surface and a cut. + final bandPaint = Paint() ..style = PaintingStyle.stroke - ..strokeWidth = 1.0; - - final rect = Rect.fromCircle( - center: Offset(centerX, centerY), - radius: radius, - ); - - canvas.drawArc( - rect, - horizontal ? -math.pi / 2 - math.pi / 2.5 : -math.pi / 2.5, - 2 * math.pi / 2.5, - false, - paint, - ); + ..strokeWidth = outerRadius - innerRadius + ..shader = RadialGradient( + colors: [ + AppColors.accent.withValues(alpha: 0.0), + if (innerStop > 0) AppColors.accent.withValues(alpha: 0.0), + AppColors.accent.withValues(alpha: 0.04), + AppColors.accent.withValues(alpha: 0.07), + ], + stops: [ + 0.0, + if (innerStop > 0) innerStop, + innerStop + (1 - innerStop) * 0.55, + 1.0, + ], + ).createShader(outerRect); + canvas.drawCircle(center, (innerRadius + outerRadius) / 2, bandPaint); + + // A single lit rim defines the edge the shading falls away from. It is + // drawn on a full circle for the same reason as the band. + final rimPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0 + ..color = AppColors.accent.withValues(alpha: 0.24); + canvas.drawCircle(center, outerRadius - 0.5, rimPaint); } @override From 36f3798ced8f8eef688e4033e5a5e8ca392f7876 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Fri, 6 Nov 2026 21:42:27 +0800 Subject: [PATCH 09/11] Add orbit viewport edge-to-edge regression tests Verify cards and the ribbon paint through gesture gutters, the mini player reservation, and the portrait shell bottom inset, plus optional screenshot output for visual inspection. --- test/features/songs/orbit_viewport_test.dart | 246 +++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 test/features/songs/orbit_viewport_test.dart diff --git a/test/features/songs/orbit_viewport_test.dart b/test/features/songs/orbit_viewport_test.dart new file mode 100644 index 00000000..a8d8850e --- /dev/null +++ b/test/features/songs/orbit_viewport_test.dart @@ -0,0 +1,246 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flick/core/theme/app_colors.dart'; +import 'package:flick/core/theme/app_theme.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; +import 'package:flick/features/songs/screens/songs_screen.dart'; +import 'package:flick/features/songs/widgets/orbit_scroll.dart'; +import 'package:flick/features/songs/widgets/song_card.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/models/song.dart'; +import 'package:flick/providers/providers.dart'; +import 'package:flick/services/app_preferences_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +final _songs = List.generate( + 40, + (i) => Song( + id: 'orbit-$i', + title: 'Song ${i.toString().padLeft(2, '0')}', + artist: 'Artist', + duration: const Duration(minutes: 4), + fileType: 'FLAC', + ), +); + +class _OrbitSongs extends SongsNotifier { + @override + Future build() async => + SongsState(songs: _songs, sortOption: SongSortOption.title); +} + +class _OrbitPreferences extends AppPreferencesNotifier { + @override + AppPreferences build() => const AppPreferences( + fastIndexEnabled: false, + orbitShowPath: false, + orbitShowGlow: false, + orbitLandscapeItemSpacing: 0.22, + orbitLandscapeDepth: 0.2, + ); + + void update({bool? path, double? depth}) { + state = state.copyWith(orbitShowPath: path, orbitLandscapeDepth: depth); + } +} + +const _shotKey = ValueKey('orbit_viewport_screenshot'); +const _screenshots = bool.fromEnvironment('ORBIT_VIEWPORT_SCREENSHOTS'); + +Future _pixels(WidgetTester tester, String name) async { + final boundary = tester.renderObject( + find.byKey(_shotKey), + ); + return (await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 1); + try { + if (_screenshots) { + final png = await image.toByteData(format: ui.ImageByteFormat.png); + final file = File('build/orbit_viewport_shots/$name.png'); + await file.parent.create(recursive: true); + await file.writeAsBytes(png!.buffer.asUint8List()); + } + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + return bytes!.buffer.asUint8List(); + } finally { + image.dispose(); + } + }))!; +} + +Future _pumpScreen( + WidgetTester tester, + Size size, { + double railWidth = AdaptiveLayout.navigationRailWidth, + double bottomInset = 0, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + songsProvider.overrideWith(_OrbitSongs.new), + appPreferencesProvider.overrideWith(_OrbitPreferences.new), + currentSongProvider.overrideWith((ref) => _songs[15]), + isCastingProvider.overrideWith((ref) => false), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + locale: kFallbackLocale, + supportedLocales: kSupportedLocales, + localizationsDelegates: kAppLocalizationsDelegates, + home: Scaffold( + backgroundColor: AppColors.background, + body: RepaintBoundary( + key: _shotKey, + child: Stack( + children: [ + const Positioned.fill( + child: ColoredBox(color: AppColors.background), + ), + Positioned.fill( + left: railWidth, + child: MediaQuery( + // Mirror the shell's content pane, including Android side + // safe areas and the full-height, clipped PageView. + data: MediaQueryData( + size: Size(size.width - railWidth, size.height), + padding: const EdgeInsets.only(left: 12, right: 20), + ), + child: PageView( + children: [ + Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: RepaintBoundary( + child: SongsScreen(pathOverflowBottom: bottomInset), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return ProviderScope.containerOf(tester.element(find.byType(SongsScreen))); +} + +int _paintedPixels(Uint8List pixels, int width, Rect area) { + var count = 0; + for (var y = area.top.ceil(); y < area.bottom.floor(); y++) { + for (var x = area.left.ceil(); x < area.right.floor(); x++) { + if (pixels[(y * width + x) * 4] > 11) count++; + } + } + return count; +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + for (final size in const [Size(900, 411), Size(1280, 800)]) { + testWidgets('landscape cards and ribbon reach both edges at $size', ( + tester, + ) async { + final container = await _pumpScreen(tester, size); + final prefs = + container.read(appPreferencesProvider.notifier) as _OrbitPreferences; + final stage = tester.getRect(find.byType(OrbitScroll)); + final width = size.width.toInt(); + final height = size.height.toInt(); + final cards = await _pixels(tester, '${width}_cards'); + + // With the ribbon/glow disabled, these can only be card pixels. They + // must survive outside both 28dp gutters, up to the page's real edges. + expect( + _paintedPixels( + cards, + width, + Rect.fromLTRB(76, stage.top, stage.left + 27, height.toDouble()), + ), + greaterThan(20), + reason: 'left cards must not stop at the gesture gutter', + ); + expect( + _paintedPixels( + cards, + width, + Rect.fromLTRB( + stage.right - 27, + stage.top, + width.toDouble(), + height.toDouble(), + ), + ), + greaterThan(20), + reason: 'right cards must continue through the mini-player reservation', + ); + + // Shrink distant cards so they do not cover the rim at the screen edges. + prefs.update(depth: 1); + await tester.pumpAndSettle(); + final withoutPath = await _pixels(tester, '${width}_without_path'); + prefs.update(path: true); + await tester.pumpAndSettle(); + final withPath = await _pixels(tester, '${width}_with_path'); + + for (final x in [77, width - 2]) { + var changed = 0; + for (var y = stage.top.ceil(); y < height; y++) { + final index = (y * width + x) * 4; + if (withPath[index] > withoutPath[index] + 2) changed++; + } + expect(changed, greaterThan(3), reason: 'ribbon must reach x=$x'); + } + expect( + withPath.sublist(0, stage.top.floor() * width * 4), + withoutPath.sublist(0, stage.top.floor() * width * 4), + reason: 'the wider ribbon must not bleed into the header/search', + ); + expect( + tester + .widget( + find.byWidgetPredicate((w) => w is SongCard && w.isSelected), + ) + .song + .id, + 'orbit-15', + ); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('portrait ribbon still paints through the shell bottom inset', ( + tester, + ) async { + final container = await _pumpScreen( + tester, + const Size(800, 1280), + railWidth: 0, + bottomInset: 180, + ); + (container.read(appPreferencesProvider.notifier) as _OrbitPreferences) + .update(path: true); + await tester.pumpAndSettle(); + final pixels = await _pixels(tester, 'portrait_bottom_overflow'); + expect( + _paintedPixels(pixels, 800, const Rect.fromLTRB(0, 1101, 800, 1280)), + greaterThan(100), + ); + expect(tester.takeException(), isNull); + }); +} From 4e089056457d257a397ff72b1112d7a216e80586 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Fri, 6 Nov 2026 22:17:13 +0800 Subject: [PATCH 10/11] Add compact landscape mini player dock Mount the dock across all tabs and animate the mini player between the bottom bar and orbit strip only for short landscape windows, leaving the static bottom player for larger layouts. --- lib/app/app.dart | 50 +++++++++++++++++------------ test/core/adaptive_layout_test.dart | 4 +++ 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/lib/app/app.dart b/lib/app/app.dart index 13f5dc5c..5aa62b38 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -37,6 +37,7 @@ import 'package:flick/features/onboarding/screens/onboarding_screen.dart'; import 'package:flick/features/onboarding/tutorial_targets.dart'; import 'package:flick/features/onboarding/widgets/tutorial_overlay.dart'; import 'package:flick/widgets/common/embedded_mini_player.dart'; +import 'package:flick/widgets/common/landscape_mini_player_dock.dart'; import 'package:flick/widgets/common/landscape_side_mini_player.dart'; import 'package:flick/widgets/navigation/bottom_bar_geometry.dart'; import 'package:flick/providers/mini_player_config_provider.dart'; @@ -794,11 +795,13 @@ class _MainShellState extends ConsumerState math.max(0.0, screenSize.width - railWidth), screenSize.height, ); + final useCompactLandscapeDock = + useSideNavigation && + AdaptiveLayout.useCompactLandscapeMiniPlayer(contentSize); final currentIndex = ref.watch(navigationIndexProvider); final songsViewMode = ref.watch(songsViewModeProvider); final sideMiniPlayerCandidate = - useSideNavigation && - AdaptiveLayout.useCompactLandscapeMiniPlayer(contentSize) && + useCompactLandscapeDock && currentIndex == NavBarButton.songs.pageIndex && songsViewMode == SongViewMode.orbit; // Only listen to the auto-disposed library provider while the orbit is on @@ -1068,9 +1071,30 @@ class _MainShellState extends ConsumerState ), ), - // In rail mode the mini player owns the bottom of the content - // area, not the full window width. - if (useSideNavigation && !useOrbitSideMiniPlayer) + // Only short landscape windows animate between the bottom + // bar and orbit strip. Keep the dock mounted across tabs. + if (useCompactLandscapeDock) + Positioned.fill( + key: const ValueKey('landscape_mini_player_dock'), + left: railWidth, + child: LandscapeMiniPlayerDock( + sideDocked: useOrbitSideMiniPlayer, + bottomPlayerHeight: + BottomBarGeometry.miniPlayerHeight( + context, + miniConfig, + ) + + 12 + + (separated ? miniConfig.navigationGap : 8), + bottomPlayer: EmbeddedMiniPlayer( + collapsed: false, + separated: separated, + ), + sidePlayer: const LandscapeSideMiniPlayer(), + ), + ) + else if (useSideNavigation) + // Larger windows retain their static bottom mini player. Positioned( left: railWidth, right: 0, @@ -1088,22 +1112,6 @@ class _MainShellState extends ConsumerState ), ), - // Compact landscape orbit moves the mini player to the right - // edge so the orbit stage keeps the full window height. - if (useOrbitSideMiniPlayer) - Positioned( - right: 6, - top: 0, - bottom: 0, - child: SafeArea( - left: false, - child: TutorialTargetAnchor( - target: TutorialTarget.miniPlayer, - child: const LandscapeSideMiniPlayer(), - ), - ), - ), - // Floating island mini-player (draggable pill) const FloatingMiniPlayer(), diff --git a/test/core/adaptive_layout_test.dart b/test/core/adaptive_layout_test.dart index 53fe2657..50d643b4 100644 --- a/test/core/adaptive_layout_test.dart +++ b/test/core/adaptive_layout_test.dart @@ -46,13 +46,17 @@ void main() { Size(520, 320), Size(640, 360), Size(891, 411), + Size(960, 479), ]) { expect(AdaptiveLayout.useCompactLandscapeMiniPlayer(size), isTrue); } for (final size in const [ Size(360, 640), Size(640, 480), + Size(960, 480), + Size(1024, 600), Size(1280, 650), + Size(1280, 800), ]) { expect(AdaptiveLayout.useCompactLandscapeMiniPlayer(size), isFalse); } From bf072f227e2ec4962f79ba241d35207b3a8c0a64 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Fri, 6 Nov 2026 22:17:22 +0800 Subject: [PATCH 11/11] Add animated landscape mini player dock Introduce a shared dock that glides the mini player between the bottom bar and the compact orbit strip for short landscape windows. Both player layouts stay mounted during the transition so only translation and opacity animate, with the inactive side muted, ignoring input, and skipped by heroes and semantics. Respect reduced motion and the app-wide animations switch by snapping instead of animating, keep a single tutorial anchor registered across handoffs, and cover the behavior with widget tests. --- .../common/landscape_mini_player_dock.dart | 174 +++++++ .../landscape_mini_player_dock_test.dart | 433 ++++++++++++++++++ .../landscape_side_mini_player_test.dart | 77 +++- 3 files changed, 680 insertions(+), 4 deletions(-) create mode 100644 lib/widgets/common/landscape_mini_player_dock.dart create mode 100644 test/widgets/landscape_mini_player_dock_test.dart diff --git a/lib/widgets/common/landscape_mini_player_dock.dart b/lib/widgets/common/landscape_mini_player_dock.dart new file mode 100644 index 00000000..41a4ba0d --- /dev/null +++ b/lib/widgets/common/landscape_mini_player_dock.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:flick/core/constants/app_constants.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; +import 'package:flick/features/onboarding/tutorial_targets.dart'; + +/// Moves between the bottom bar and compact orbit strip without laying out +/// either player on every animation frame. +class LandscapeMiniPlayerDock extends StatefulWidget { + const LandscapeMiniPlayerDock({ + super.key, + required this.sideDocked, + required this.bottomPlayerHeight, + required this.bottomPlayer, + required this.sidePlayer, + }); + + final bool sideDocked; + final double bottomPlayerHeight; + final Widget bottomPlayer; + final Widget sidePlayer; + + @override + State createState() => + _LandscapeMiniPlayerDockState(); +} + +class _LandscapeMiniPlayerDockState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + value: widget.sideDocked ? 1 : 0, + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _syncMotion(); + } + + @override + void didUpdateWidget(LandscapeMiniPlayerDock oldWidget) { + super.didUpdateWidget(oldWidget); + _syncMotion(move: oldWidget.sideDocked != widget.sideDocked); + } + + void _syncMotion({bool move = false}) { + final disabled = + MediaQuery.disableAnimationsOf(context) || + AppConstants.animationNormal == Duration.zero; + _controller.duration = disabled + ? Duration.zero + : const Duration(milliseconds: 220); + final target = widget.sideDocked ? 1.0 : 0.0; + if (disabled) { + _controller.value = target; + } else if (move) { + // animateTo starts at the current position, including on rapid reversals. + _controller.animateTo(target, curve: Curves.easeOutCubic); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Widget _player(Widget child, {required bool active}) => HeroMode( + enabled: active, + child: TickerMode( + enabled: active, + child: ExcludeSemantics( + excluding: !active, + child: IgnorePointer( + ignoring: !active, + child: RepaintBoundary(child: child), + ), + ), + ), + ); + + @override + Widget build(BuildContext context) { + // Match the original bottom player's safe area; the side strip additionally + // leaves room for the top system inset. + final topInset = MediaQuery.paddingOf(context).top; + return SafeArea( + top: false, + left: false, + child: LayoutBuilder( + builder: (context, constraints) { + final bottomRect = Rect.fromLTWH( + 0, + constraints.maxHeight - widget.bottomPlayerHeight, + constraints.maxWidth, + widget.bottomPlayerHeight, + ); + final sideRect = Rect.fromLTWH( + constraints.maxWidth - AdaptiveLayout.sideMiniPlayerWidth - 6, + topInset, + AdaptiveLayout.sideMiniPlayerWidth, + (constraints.maxHeight - topInset).clamp(0.0, double.infinity), + ); + final travel = sideRect.center - bottomRect.center; + // These subtrees stay fixed during ticks. Only translation and opacity + // change; the outgoing visualizer is muted and removed on completion. + final bottomPlayer = _player( + widget.bottomPlayer, + active: !widget.sideDocked, + ); + final sidePlayer = _player( + widget.sidePlayer, + active: widget.sideDocked, + ); + final anchor = IgnorePointer( + child: TutorialTargetAnchor( + target: TutorialTarget.miniPlayer, + child: const SizedBox.expand(), + ), + ); + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final t = _controller.value; + final bottomOffset = travel * t; + final sideOffset = travel * (t - 1); + return ClipRect( + child: Stack( + children: [ + if (t < 1) + Positioned.fromRect( + key: const ValueKey('bottom_mini_player_dock'), + rect: bottomRect, + child: Transform.translate( + offset: bottomOffset, + child: Opacity(opacity: 1 - t, child: bottomPlayer), + ), + ), + if (t > 0) + Positioned.fromRect( + key: const ValueKey('side_mini_player_dock'), + rect: sideRect, + child: Transform.translate( + offset: sideOffset, + child: Opacity(opacity: t, child: sidePlayer), + ), + ), + // One persistent anchor avoids duplicate registrations while + // both player layouts briefly share the dock. + Positioned.fromRect( + key: const ValueKey('mini_player_dock_anchor'), + rect: widget.sideDocked ? sideRect : bottomRect, + child: Transform.translate( + offset: widget.sideDocked ? sideOffset : bottomOffset, + child: anchor, + ), + ), + ], + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/test/widgets/landscape_mini_player_dock_test.dart b/test/widgets/landscape_mini_player_dock_test.dart new file mode 100644 index 00000000..34436442 --- /dev/null +++ b/test/widgets/landscape_mini_player_dock_test.dart @@ -0,0 +1,433 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flick/core/constants/app_constants.dart'; +import 'package:flick/features/onboarding/tutorial_targets.dart'; +import 'package:flick/widgets/common/landscape_mini_player_dock.dart'; + +const _bottomKey = ValueKey('bottom_player'); +const _sideKey = ValueKey('side_player'); + +Widget _host({ + required bool sideDocked, + bool reducedMotion = false, + EdgeInsets padding = EdgeInsets.zero, + Widget bottomPlayer = const SizedBox.expand(key: _bottomKey), + Widget sidePlayer = const SizedBox.expand(key: _sideKey), +}) => ProviderScope( + child: MaterialApp( + home: MediaQuery( + data: MediaQueryData( + size: const Size(800, 400), + padding: padding, + disableAnimations: reducedMotion, + ), + child: Scaffold( + body: Stack( + children: [ + Positioned.fill( + left: 76, + child: LandscapeMiniPlayerDock( + sideDocked: sideDocked, + bottomPlayerHeight: 80, + bottomPlayer: bottomPlayer, + sidePlayer: sidePlayer, + ), + ), + ], + ), + ), + ), + ), +); + +class _Counts { + int builds = 0; + int layouts = 0; +} + +class _PlayerProbe extends StatelessWidget { + const _PlayerProbe(this.counts); + + final _Counts counts; + + @override + Widget build(BuildContext context) { + counts.builds++; + return _LayoutProbe(counts); + } +} + +class _LayoutProbe extends SingleChildRenderObjectWidget { + const _LayoutProbe(this.counts) : super(child: const SizedBox.expand()); + + final _Counts counts; + + @override + RenderObject createRenderObject(BuildContext context) => + _LayoutCounter(counts); +} + +class _LayoutCounter extends RenderProxyBox { + _LayoutCounter(this.counts); + + final _Counts counts; + + @override + void performLayout() { + counts.layouts++; + super.performLayout(); + } +} + +void main() { + setUp(() => AppConstants.setAnimationsEnabled(true)); + tearDown(() => AppConstants.setAnimationsEnabled(true)); + + Future setLandscape(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(800, 400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + } + + testWidgets('starts at either dock without an entrance animation', ( + tester, + ) async { + await setLandscape(tester); + await tester.pumpWidget(_host(sideDocked: false)); + await tester.pump(); + expect( + tester.getRect(find.byKey(_bottomKey)), + const Rect.fromLTWH(76, 320, 724, 80), + ); + expect(find.byKey(_sideKey), findsNothing); + expect(tester.binding.hasScheduledFrame, isFalse); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpWidget(_host(sideDocked: true)); + await tester.pump(); + expect( + tester.getRect(find.byKey(_sideKey)), + const Rect.fromLTWH(730, 0, 64, 400), + ); + expect(find.byKey(_bottomKey), findsNothing); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('glides both fixed-size players along one path in 220ms', ( + tester, + ) async { + await setLandscape(tester); + await tester.pumpWidget(_host(sideDocked: false)); + final start = tester.getRect(find.byKey(_bottomKey)); + + await tester.pumpWidget(_host(sideDocked: true)); + expect(tester.getRect(find.byKey(_bottomKey)), start); + await tester.pump(const Duration(milliseconds: 80)); + + final bottom = tester.getRect(find.byKey(_bottomKey)); + final side = tester.getRect(find.byKey(_sideKey)); + expect(tester.getSize(find.byKey(_bottomKey)), start.size); + expect(tester.getSize(find.byKey(_sideKey)), const Size(64, 400)); + expect(bottom.center.dx, greaterThan(start.center.dx)); + expect(bottom.center.dx, lessThan(762)); + expect(bottom.center.dy, lessThan(start.center.dy)); + expect((bottom.center - side.center).distance, lessThan(0.001)); + + await tester.pump(const Duration(milliseconds: 140)); + expect(find.byKey(_bottomKey), findsNothing); + expect(tester.getCenter(find.byKey(_sideKey)), const Offset(762, 200)); + // The ticker stops on the frame following the exact duration boundary. + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.binding.transientCallbackCount, 0); + expect(tester.binding.hasScheduledFrame, isFalse); + + await tester.pumpWidget(_host(sideDocked: false)); + await tester.pump(const Duration(milliseconds: 80)); + final returning = tester.getCenter(find.byKey(_bottomKey)); + expect(returning.dx, greaterThan(start.center.dx)); + expect(returning.dx, lessThan(762)); + expect(returning.dy, greaterThan(200)); + await tester.pump(const Duration(milliseconds: 140)); + expect(tester.getRect(find.byKey(_bottomKey)), start); + expect(find.byKey(_sideKey), findsNothing); + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.binding.transientCallbackCount, 0); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('rapid reversals continue from the current position', ( + tester, + ) async { + await setLandscape(tester); + await tester.pumpWidget(_host(sideDocked: false)); + await tester.pumpWidget(_host(sideDocked: true)); + await tester.pump(const Duration(milliseconds: 64)); + final before = tester.getCenter(find.byKey(_sideKey)); + + await tester.pumpWidget(_host(sideDocked: false)); + expect(tester.getCenter(find.byKey(_sideKey)), before); + await tester.pump(const Duration(milliseconds: 32)); + final reversed = tester.getCenter(find.byKey(_bottomKey)); + expect(reversed.dx, lessThan(before.dx)); + expect(reversed.dy, greaterThan(before.dy)); + + await tester.pumpWidget(_host(sideDocked: true)); + expect(tester.getCenter(find.byKey(_bottomKey)), reversed); + await tester.pump(const Duration(milliseconds: 220)); + expect(find.byKey(_bottomKey), findsNothing); + expect(find.byKey(_sideKey), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('animation ticks do not rebuild or lay out either player', ( + tester, + ) async { + await setLandscape(tester); + final bottom = _Counts(); + final side = _Counts(); + final bottomPlayer = _PlayerProbe(bottom); + final sidePlayer = _PlayerProbe(side); + + Future show(bool sideDocked) => tester.pumpWidget( + _host( + sideDocked: sideDocked, + bottomPlayer: bottomPlayer, + sidePlayer: sidePlayer, + ), + ); + + await show(false); + await show(true); + await tester.pump(const Duration(milliseconds: 16)); + final builds = (bottom.builds, side.builds); + final layouts = (bottom.layouts, side.layouts); + + for (var frame = 0; frame < 10; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect((bottom.builds, side.builds), builds); + expect((bottom.layouts, side.layouts), layouts); + + await tester.pump(const Duration(milliseconds: 220)); + expect(find.byType(_PlayerProbe), findsOneWidget); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('reduced motion snaps to the target, including mid-flight', ( + tester, + ) async { + await setLandscape(tester); + await tester.pumpWidget(_host(sideDocked: false)); + await tester.pumpWidget(_host(sideDocked: true)); + await tester.pump(const Duration(milliseconds: 48)); + await tester.pumpWidget(_host(sideDocked: true, reducedMotion: true)); + expect(find.byKey(_bottomKey), findsNothing); + expect(tester.getCenter(find.byKey(_sideKey)), const Offset(762, 200)); + + await tester.pumpWidget(_host(sideDocked: false, reducedMotion: true)); + expect(find.byKey(_sideKey), findsNothing); + expect(tester.getCenter(find.byKey(_bottomKey)), const Offset(438, 360)); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('respects the app-wide animations switch', (tester) async { + await setLandscape(tester); + await tester.pumpWidget(_host(sideDocked: true)); + AppConstants.setAnimationsEnabled(false); + await tester.pumpWidget(_host(sideDocked: false)); + expect(find.byKey(_sideKey), findsNothing); + expect(tester.getCenter(find.byKey(_bottomKey)), const Offset(438, 360)); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('keeps safe insets and one tutorial anchor through handoffs', ( + tester, + ) async { + await setLandscape(tester); + const padding = EdgeInsets.only(top: 24, right: 20, bottom: 16); + await tester.pumpWidget(_host(sideDocked: false, padding: padding)); + expect( + tester.getRect(find.byKey(_bottomKey)), + const Rect.fromLTWH(76, 304, 704, 80), + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(LandscapeMiniPlayerDock)), + ); + final registry = container.read(tutorialTargetRegistryProvider); + + await tester.pumpWidget(_host(sideDocked: true, padding: padding)); + await tester.pump(const Duration(milliseconds: 80)); + expect(find.byType(TutorialTargetAnchor), findsOneWidget); + expect( + registry.rectFor(TutorialTarget.miniPlayer), + tester.getRect(find.byKey(_sideKey)), + ); + + await tester.pump(const Duration(milliseconds: 140)); + expect( + tester.getRect(find.byKey(_sideKey)), + const Rect.fromLTWH(710, 24, 64, 360), + ); + expect( + registry.rectFor(TutorialTarget.miniPlayer), + tester.getRect(find.byKey(_sideKey)), + ); + }); + + testWidgets( + 'only the target is interactive, ticking, and available to heroes', + (tester) async { + await setLandscape(tester); + var bottomTaps = 0; + var sideTaps = 0; + final bottomPlayer = Hero( + tag: 'mini_player_art', + child: GestureDetector( + key: _bottomKey, + behavior: HitTestBehavior.opaque, + onTap: () => bottomTaps++, + child: const SizedBox.expand(), + ), + ); + final sidePlayer = Hero( + tag: 'mini_player_art', + child: GestureDetector( + key: _sideKey, + behavior: HitTestBehavior.opaque, + onTap: () => sideTaps++, + child: const SizedBox.expand(), + ), + ); + Future show(bool sideDocked) => tester.pumpWidget( + _host( + sideDocked: sideDocked, + bottomPlayer: bottomPlayer, + sidePlayer: sidePlayer, + ), + ); + + await show(false); + await show(true); + await tester.pump(const Duration(milliseconds: 80)); + final bottomContext = tester.element(find.byKey(_bottomKey)); + final sideContext = tester.element(find.byKey(_sideKey)); + expect(TickerMode.valuesOf(bottomContext).enabled, isFalse); + expect(TickerMode.valuesOf(sideContext).enabled, isTrue); + expect( + bottomContext.findAncestorWidgetOfExactType()!.enabled, + isFalse, + ); + expect( + sideContext.findAncestorWidgetOfExactType()!.enabled, + isTrue, + ); + expect( + bottomContext + .findAncestorWidgetOfExactType()! + .excluding, + isTrue, + ); + expect( + sideContext + .findAncestorWidgetOfExactType()! + .excluding, + isFalse, + ); + await tester.tap(find.byKey(_sideKey)); + expect(sideTaps, 1); + expect(bottomTaps, 0); + + Navigator.of(sideContext).push( + MaterialPageRoute( + builder: (_) => const Scaffold( + body: Hero( + tag: 'mini_player_art', + child: SizedBox(width: 100, height: 100), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'keeps dock state when an upper sibling disappears with the mode change', + (tester) async { + await setLandscape(tester); + + // Mirrors MainShell: a conditional overlay above the dock (the nav-inset + // blocker) is removed in the same rebuild that engages the side player. + Widget shellHost({required bool sideDocked}) => ProviderScope( + child: MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(800, 400)), + child: Scaffold( + body: Stack( + children: [ + if (!sideDocked) + Positioned( + left: 76, + right: 0, + bottom: 0, + height: 90, + child: const ColoredBox( + color: Colors.black, + child: SizedBox.expand(key: ValueKey('blocker')), + ), + ), + Positioned.fill( + key: const ValueKey('landscape_mini_player_dock'), + left: 76, + child: LandscapeMiniPlayerDock( + sideDocked: sideDocked, + bottomPlayerHeight: 80, + bottomPlayer: const SizedBox.expand(key: _bottomKey), + sidePlayer: const SizedBox.expand(key: _sideKey), + ), + ), + ], + ), + ), + ), + ), + ); + + await tester.pumpWidget(shellHost(sideDocked: false)); + final start = tester.getRect(find.byKey(_bottomKey)); + + // The blocker vanishes as the side dock engages; the glide must still + // start from the bottom instead of remounting at the target. + await tester.pumpWidget(shellHost(sideDocked: true)); + await tester.pump(const Duration(milliseconds: 16)); + expect(find.byKey(_bottomKey), findsOneWidget); + expect(find.byKey(_sideKey), findsOneWidget); + final mid = tester.getRect(find.byKey(_bottomKey)); + expect(mid.center.dx, greaterThan(start.center.dx)); + expect(mid.center.dx, lessThan(762)); + + await tester.pump(const Duration(milliseconds: 204)); + expect(find.byKey(_bottomKey), findsNothing); + expect(tester.getCenter(find.byKey(_sideKey)), const Offset(762, 200)); + + // Returning to the bottom re-inserts the blocker; the outgoing side + // strip must still glide back instead of vanishing instantly. + await tester.pumpWidget(shellHost(sideDocked: false)); + await tester.pump(const Duration(milliseconds: 16)); + expect(find.byKey(_sideKey), findsOneWidget); + expect(find.byKey(_bottomKey), findsOneWidget); + final back = tester.getCenter(find.byKey(_sideKey)); + expect(back.dy, greaterThan(200)); + expect(back.dy, lessThan(360)); + + await tester.pump(const Duration(milliseconds: 204)); + expect(find.byKey(_sideKey), findsNothing); + expect(tester.getRect(find.byKey(_bottomKey)), start); + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.binding.hasScheduledFrame, isFalse); + }, + ); +} diff --git a/test/widgets/landscape_side_mini_player_test.dart b/test/widgets/landscape_side_mini_player_test.dart index 1231845d..48a24f1a 100644 --- a/test/widgets/landscape_side_mini_player_test.dart +++ b/test/widgets/landscape_side_mini_player_test.dart @@ -12,7 +12,10 @@ import 'package:flick/models/song.dart'; import 'package:flick/providers/mini_player_config_provider.dart'; import 'package:flick/providers/providers.dart'; import 'package:flick/services/player_service.dart'; +import 'package:flick/widgets/common/embedded_mini_player.dart'; +import 'package:flick/widgets/common/landscape_mini_player_dock.dart'; import 'package:flick/widgets/common/landscape_side_mini_player.dart'; +import 'package:flick/widgets/navigation/bottom_bar_geometry.dart'; const _song = Song( id: 'side-player-track', @@ -45,6 +48,7 @@ Widget _host( ProviderContainer container, { required bool animationsEnabled, double height = 360, + Widget child = const LandscapeSideMiniPlayer(), }) => UncontrolledProviderScope( container: container, child: MaterialApp( @@ -62,10 +66,7 @@ Widget _host( alignment: Alignment.centerRight, child: SizedBox( height: height, - child: TickerMode( - enabled: animationsEnabled, - child: const LandscapeSideMiniPlayer(), - ), + child: TickerMode(enabled: animationsEnabled, child: child), ), ), ), @@ -280,4 +281,72 @@ void main() { ); await tester.pumpWidget(const SizedBox.shrink()); }); + + for (final separated in [false, true]) { + testWidgets('dock preserves real player layouts, separated: $separated', ( + tester, + ) async { + final player = _Player(); + addTearDown(player.dispose); + final container = _container(player); + await container + .read(miniPlayerConfigProvider.notifier) + .update( + (config) => + config.copyWith(height: 72, navigationGap: 20, textScale: 1.8), + ); + + Future show(bool sideDocked) => tester.pumpWidget( + _host( + container, + animationsEnabled: true, + child: Builder( + builder: (context) { + final config = container.read(miniPlayerConfigProvider); + return LandscapeMiniPlayerDock( + sideDocked: sideDocked, + bottomPlayerHeight: + BottomBarGeometry.miniPlayerHeight(context, config) + + 12 + + (separated ? config.navigationGap : 8), + bottomPlayer: EmbeddedMiniPlayer(separated: separated), + sidePlayer: const LandscapeSideMiniPlayer(), + ); + }, + ), + ), + ); + + await show(false); + await tester.pump(); + final bottomSurface = tester.getRect( + find.byKey(const ValueKey('mini_player_surface')), + ); + expect(bottomSurface.height, 72); + + await show(true); + await tester.pump(const Duration(milliseconds: 80)); + expect(find.byType(EmbeddedMiniPlayer), findsOneWidget); + expect(find.byType(LandscapeSideMiniPlayer), findsOneWidget); + expect( + tester.getSize(find.byKey(const ValueKey('mini_player_surface'))), + bottomSurface.size, + ); + expect(tester.takeException(), isNull); + + await tester.pump(const Duration(milliseconds: 140)); + expect(find.byType(EmbeddedMiniPlayer), findsNothing); + expect(tester.getSize(find.byType(LandscapeSideMiniPlayer)).width, 64); + + await show(false); + await tester.pump(const Duration(milliseconds: 220)); + expect(find.byType(LandscapeSideMiniPlayer), findsNothing); + expect( + tester.getRect(find.byKey(const ValueKey('mini_player_surface'))), + bottomSurface, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } }