feat(library): refine album and artist artwork headers

Raise album details and artist actions over the artwork, soften the lower artist banner, and derive action colors from the artist logo. Match loading skeletons to the updated header layouts and preserve scrolling and large text behavior.
This commit is contained in:
zarzet committed 2026-09-25 01:20:54 +07:00
1 parent bb057fd210
commit 9a62244263
8 files changed
+697 -287

No files matched your search

+186 -3
View File
@@ -1,12 +1,111 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/theme/cover_palette.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
import 'package:spotiflac_android/widgets/mornye_artist_header.dart';
void main() {
testWidgets(
'local and online album actions share the neutral cover surface',
(tester) async {
final directory = Directory.systemTemp.createTempSync('album-surface-');
addTearDown(() => directory.deleteSync(recursive: true));
final scrollController = ScrollController();
addTearDown(scrollController.dispose);
final cover = File('${directory.path}/cover.png');
await tester.runAsync(() async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawColor(Colors.white, BlendMode.src);
canvas.drawRect(
const Rect.fromLTWH(50, 0, 50, 100),
Paint()..color = const Color(0xff02020f),
);
final picture = recorder.endRecording();
final image = await picture.toImage(100, 100);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
picture.dispose();
await cover.writeAsBytes(bytes!.buffer.asUint8List());
await CoverPalette.resolve(cover.path, Brightness.dark);
});
for (final online in [false, true]) {
final label = online ? 'Download' : 'Play';
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.light),
home: MornyeArtistSurface(
imageSource: cover.path,
neutralActions: true,
child: CollectionScaffold(
scrollController: scrollController,
isSelectionMode: false,
onExitSelectionMode: () {},
bottomInset: 0,
appBar: AlbumDetailHeader(
title: 'Monochrome album',
expandedHeight: 400,
showTitleInAppBar: false,
immersive: true,
background: const ColoredBox(color: Colors.white),
actions: online
? HeaderFilledButton(
icon: CupertinoIcons.arrow_down_circle_fill,
label: label,
onPressed: () {},
)
: AlbumPlayActions(
playLabel: label,
shuffleTooltip: 'Shuffle',
onPlay: () {},
onShuffle: () {},
),
),
slivers: const [],
),
),
),
),
);
await tester.pumpAndSettle();
final action = find.text(label);
final context = tester.element(action);
final scheme = Theme.of(context).colorScheme;
expect(scheme.surface.r, scheme.surface.g);
expect(scheme.surface.g, scheme.surface.b);
expect(HeaderPalette.of(context), scheme);
expect(tester.widget<Text>(action).style?.color, Colors.black);
expect(
tester
.widget<CupertinoButton>(
find.ancestor(
of: action,
matching: find.byType(CupertinoButton),
),
)
.color,
Colors.white,
);
if (!online) {
expect(
tester.widget<Text>(find.text('Shuffle')).style?.color,
Colors.white,
);
}
expect(tester.takeException(), isNull);
}
await tester.pumpWidget(const SizedBox());
},
);
testWidgets('unavailable artist logo keeps the name and actions readable', (
tester,
) async {
@@ -37,13 +136,20 @@ void main() {
expect(
find
.descendant(
of: find.byType(SliverToBoxAdapter),
of: find.byType(FlexibleSpaceBar),
matching: find.text('Example Artist'),
)
.hitTestable(),
findsOneWidget,
);
expect(find.text('Artist action').hitTestable(), findsOneWidget);
final background = tester.getRect(
find.byKey(const ValueKey('artist-artwork-fade')),
);
final action = tester.getRect(find.text('Artist action'));
expect(background.contains(action.center), isTrue);
expect(background.bottom - action.bottom, inInclusiveRange(32, 60));
expect(action.center.dy, lessThan(480));
expect(tester.takeException(), isNull);
});
@@ -99,18 +205,19 @@ void main() {
expect(fade(), inExclusiveRange(0, 1));
expect(find.text('Artist action').hitTestable(), findsOneWidget);
controller.jumpTo(210);
controller.jumpTo(340);
await tester.pumpAndSettle();
expect(fade(), 1);
expect(filter().enabled, isFalse);
expect(TickerMode.valuesOf(artwork).enabled, isFalse);
expect(find.text('Artist action').hitTestable(), findsOneWidget);
expect(find.byTooltip('Back').hitTestable(), findsOneWidget);
controller.jumpTo(0);
await tester.pumpAndSettle();
expect(fade(), 0);
expect(filter().enabled, isFalse);
expect(TickerMode.valuesOf(artwork).enabled, isTrue);
expect(find.text('Artist action').hitTestable(), findsOneWidget);
expect(tester.element(find.byKey(artworkKey)), same(artwork));
expect(tester.takeException(), isNull);
});
@@ -186,6 +293,13 @@ void main() {
tester.getSize(find.byKey(const ValueKey('album-artwork'))),
const Size(390, 390),
);
final albumTitle = find
.text(
'A long album title that must wrap without hiding any of its controls',
)
.last;
expect(tester.getTopLeft(albumTitle).dy, 370);
expect(find.byTooltip('Back').hitTestable(), findsOneWidget);
await tester.ensureVisible(find.text('Play'));
await tester.pumpAndSettle();
await tester.tap(find.text('Play'));
@@ -203,6 +317,67 @@ void main() {
}
}
testWidgets('album banner extends behind the raised details and actions', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
tester.view.padding = FakeViewPadding(top: 59, bottom: 34);
addTearDown(tester.view.reset);
final controller = ScrollController();
addTearDown(controller.dispose);
var downloads = 0;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.dark),
home: CollectionScaffold(
scrollController: controller,
isSelectionMode: false,
onExitSelectionMode: () {},
bottomInset: 0,
appBar: AlbumDetailHeader(
title: 'Album title',
expandedHeight: 400,
showTitleInAppBar: false,
immersive: true,
squareArtwork: false,
background: const ColoredBox(
key: ValueKey('album-banner'),
color: Colors.orange,
),
subtitle: const Text('Artist name'),
meta: const Text('2026 · Lossless'),
actions: HeaderFilledButton(
icon: CupertinoIcons.arrow_down_circle_fill,
label: 'Download',
onPressed: () => downloads++,
),
),
slivers: const [SliverToBoxAdapter(child: SizedBox(height: 2000))],
),
),
),
);
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Album title').last).dy, 370);
final banner = tester.getRect(find.byKey(const ValueKey('album-banner')));
final action = tester.getRect(find.text('Download'));
expect(banner.top, 0);
expect(banner.contains(action.bottomRight), isTrue);
expect(action.center.dy, lessThan(520));
expect(find.byTooltip('Back').hitTestable(), findsOneWidget);
await tester.tap(find.text('Download'));
expect(downloads, 1);
controller.jumpTo(1000);
await tester.pumpAndSettle();
expect(find.byTooltip('Back').hitTestable(), findsOneWidget);
controller.jumpTo(0);
await tester.pumpAndSettle();
expect(find.text('Download').hitTestable(), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets(
'artist identity grows with text and retains navigation after scroll',
(tester) async {
@@ -216,6 +391,12 @@ void main() {
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.dark),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: const TextScaler.linear(2)),
child: child!,
),
home: Builder(
builder: (context) => Scaffold(
body: CustomScrollView(
@@ -242,6 +423,8 @@ void main() {
),
);
await tester.pumpAndSettle();
await tester.drag(find.byType(CustomScrollView), const Offset(0, -600));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Favorite'));
expect(favorites, 1);
controller.jumpTo(1000);