diff --git a/integration_test/mornye_download_picker_test.dart b/integration_test/mornye_download_picker_test.dart new file mode 100644 index 00000000..3482dd84 --- /dev/null +++ b/integration_test/mornye_download_picker_test.dart @@ -0,0 +1,199 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +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:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/track_detail_actions.dart'; + +import 'performance_probe.dart'; + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(); +} + +class _Extensions extends ExtensionNotifier { + @override + ExtensionState build() => ExtensionState( + extensions: [ + for (var source = 0; source < 4; source++) + Extension( + id: 'source-$source', + name: 'source-$source', + displayName: 'Source $source', + version: '1.0.0', + description: '', + enabled: true, + status: 'loaded', + hasDownloadProvider: true, + qualityOptions: [ + for (var quality = 0; quality < 16; quality++) + QualityOption( + id: 'quality-$quality', + label: 'Quality $quality', + description: 'Example audio format', + ), + ], + ), + ], + ); + + @override + void refreshEnabledExtensionHealth({bool force = false}) {} +} + +void main() { + final binding = PerformanceTestBinding.ensureInitialized(); + for (final brightness in Brightness.values) { + testWidgets( + 'download glass, source changes and queue feedback in $brightness', + (tester) async { + final probe = PerformanceProbe( + binding, + tester, + suite: 'download_picker_${brightness.name}', + ); + expect(ui.ImageFilter.isShaderFilterSupported, isTrue); + final capture = GlobalKey(); + (String, String)? selection; + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + extensionProvider.overrideWith(_Extensions.new), + ], + child: MaterialApp( + theme: MornyeTheme.build(brightness), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (_, child) => RepaintBoundary( + key: capture, + child: AppScaffoldMessenger(child: child!), + ), + home: Scaffold( + body: Builder( + builder: (context) => Stack( + fit: StackFit.expand, + children: [ + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xff76534b), + Color(0xff385763), + Color(0xff92836b), + ], + ), + ), + ), + Center( + child: TextButton( + onPressed: () => DownloadServicePicker.show( + context, + trackName: 'Example track', + artistName: 'Example artist', + onSelect: (quality, service) { + selection = (quality, service); + showQueuedSnackbar(context, 3, 0); + }, + ), + child: const Text('Open download'), + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.runAsync(() => LiquidGlassShaders.ensureLoaded()); + await probe.wait(const Duration(milliseconds: 600)); + await tester.tap(find.text('Open download')); + await probe.wait(const Duration(milliseconds: 500)); + expect(find.byType(MornyeGlassPanel), findsOneWidget); + Future savePose(String pose) async { + await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage( + pixelRatio: tester.view.devicePixelRatio, + ); + try { + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.png, + ))!; + final directory = await getTemporaryDirectory(); + final file = File( + '${directory.path}/download-${brightness.name}-$pose.png', + ); + await file.writeAsBytes( + bytes.buffer.asUint8List( + bytes.offsetInBytes, + bytes.lengthInBytes, + ), + ); + debugPrint('DOWNLOAD_GLASS_POSE: ${file.path}'); + } finally { + image.dispose(); + } + }); + } + + await savePose('panel'); + await probe.measure('change_sources', () async { + for (final source in [1, 2, 3, 0]) { + await tester.tap(find.text('Source $source')); + await probe.wait(const Duration(milliseconds: 150)); + } + }); + final scroll = tester.state( + find.descendant( + of: find.byType(DownloadServicePicker), + matching: find.byType(Scrollable), + ), + ); + await probe.measure('scroll_qualities', () async { + await scroll.position.animateTo( + scroll.position.maxScrollExtent, + duration: const Duration(milliseconds: 500), + curve: Curves.linear, + ); + await scroll.position.animateTo( + 0, + duration: const Duration(milliseconds: 500), + curve: Curves.linear, + ); + }); + await tester.tap(find.text('Quality 1')); + await probe.wait(const Duration(milliseconds: 500)); + expect(selection, ('quality-1', 'source-0')); + expect(find.byType(DownloadServicePicker), findsNothing); + expect(find.byType(MornyeGlassPanel), findsOneWidget); + await savePose('queue'); + await probe.finish( + metadata: { + 'scope': + 'production download picker and queue feedback with fixture providers; no downloads', + 'brightness': brightness.name, + }, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } +} diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 889ead6e..8eb36eb1 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -40,19 +40,23 @@ class DownloadServicePicker extends ConsumerStatefulWidget { required void Function(String quality, String service) onSelect, }) { final colorScheme = Theme.of(context).colorScheme; + final mornye = context.isMornye; showModalBottomSheet( context: context, useRootNavigator: true, - backgroundColor: context.isMornye + backgroundColor: mornye ? Colors.transparent : colorScheme.surfaceContainerHigh, - elevation: context.isMornye ? 0 : null, - barrierColor: context.isMornye - ? Colors.black.withValues(alpha: 0.26) + elevation: mornye ? 0 : null, + // Only the glass draws the sheet outline, including its bottom corners. + shape: mornye ? const RoundedRectangleBorder() : null, + clipBehavior: mornye ? Clip.none : null, + barrierColor: mornye + ? CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context) : null, isScrollControlled: true, - showDragHandle: !context.isMornye, + showDragHandle: !mornye, constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.88, ), @@ -65,16 +69,16 @@ class DownloadServicePicker extends ConsumerStatefulWidget { recommendedService: recommendedService, ); return context.isMornye - ? MornyeGlassPanel.overlay( - tintOpacity: colorScheme.brightness == Brightness.dark - ? 0.68 - : 0.75, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const AppSheetHandle(), - Flexible(child: picker), - ], + ? Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: MornyeGlassPanel.overlay( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppSheetHandle(), + Flexible(child: picker), + ], + ), ), ) : picker; @@ -103,7 +107,12 @@ class _DownloadServicePickerState extends ConsumerState { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - ref.read(extensionProvider.notifier).refreshEnabledExtensionHealth(); + final notifier = ref.read(extensionProvider.notifier); + for (final extension in _downloadExtensions()) { + if (extension.hasServiceHealth) { + unawaited(notifier.checkExtensionHealth(extension.id)); + } + } }); final downloadExtensions = _downloadExtensions(); final recommended = widget.recommendedService; @@ -132,6 +141,7 @@ class _DownloadServicePickerState extends ConsumerState { } void _selectService(Extension extension) { + if (_selectedService == extension.id) return; setState(() => _selectedService = extension.id); if (extension.hasServiceHealth) { unawaited( @@ -143,8 +153,12 @@ class _DownloadServicePickerState extends ConsumerState { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final extensionState = ref.watch(extensionProvider); - final downloadExtensions = _downloadExtensions(); + final extensions = ref.watch( + extensionProvider.select((state) => state.extensions), + ); + final downloadExtensions = extensions + .where((ext) => ext.enabled && ext.hasDownloadProvider) + .toList(growable: false); final hasProviders = downloadExtensions.isNotEmpty; final qualityOptions = _getQualityOptions(downloadExtensions); final qualityRows = [ @@ -199,10 +213,11 @@ class _DownloadServicePickerState extends ConsumerState { children: [ for (final ext in downloadExtensions) _ServiceChip( + key: ValueKey(ext.id), label: ext.displayName, isRecommended: widget.recommendedService == ext.id, - healthStatus: ext.hasServiceHealth - ? extensionState.healthStatuses[ext.id]?.status + healthServiceId: ext.hasServiceHealth + ? ext.id : null, isSelected: _selectedService == ext.id, onTap: () => _selectService(ext), @@ -233,10 +248,7 @@ class _DownloadServicePickerState extends ConsumerState { if (context.isMornye) Padding( padding: const EdgeInsets.symmetric(horizontal: 20), - child: Material( - color: MornyeTheme.controlFill(context), - borderRadius: BorderRadius.circular(24), - clipBehavior: Clip.antiAlias, + child: RepaintBoundary( child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -407,15 +419,16 @@ class _ServiceChip extends StatelessWidget { final bool isRecommended; final VoidCallback? onTap; final String? iconPath; - final String? healthStatus; + final String? healthServiceId; const _ServiceChip({ + super.key, required this.label, required this.isSelected, required this.onTap, this.isRecommended = false, this.iconPath, - this.healthStatus, + this.healthServiceId, }); @override @@ -435,15 +448,25 @@ class _ServiceChip extends StatelessWidget { borderRadius: BorderRadius.circular(24), color: isSelected ? colorScheme.primary.withValues(alpha: 0.14) - : MornyeTheme.controlFill(context), + : colorScheme.onSurface.withValues(alpha: 0.05), onPressed: onTap, child: Row( mainAxisSize: MainAxisSize.min, children: [ - if (isSelected) ...[ - Icon(CupertinoIcons.checkmark, color: foreground, size: 18), - const SizedBox(width: 8), - ], + // Reserve selection space so switching sources never reflows + // the chips and changes the sheet height under the finger. + SizedBox( + width: 18, + height: 18, + child: isSelected + ? Icon( + CupertinoIcons.checkmark, + color: foreground, + size: 18, + ) + : null, + ), + const SizedBox(width: 8), if (iconPath != null) ...[ ClipRRect( borderRadius: BorderRadius.circular(4), @@ -451,6 +474,10 @@ class _ServiceChip extends StatelessWidget { File(iconPath!), width: 18, height: 18, + cacheWidth: (18 * MediaQuery.devicePixelRatioOf(context)) + .ceil(), + cacheHeight: (18 * MediaQuery.devicePixelRatioOf(context)) + .ceil(), fit: BoxFit.cover, errorBuilder: (_, _, _) => Icon( CupertinoIcons.square_grid_2x2, @@ -466,7 +493,7 @@ class _ServiceChip extends StatelessWidget { label, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: foreground, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + fontWeight: FontWeight.w600, ), ), ), @@ -482,9 +509,9 @@ class _ServiceChip extends StatelessWidget { ), ), ], - if (healthStatus != null) ...[ + if (healthServiceId != null) ...[ const SizedBox(width: 8), - _ServiceHealthDot(status: healthStatus!), + _ServiceHealthDot(serviceId: healthServiceId!), ], ], ), @@ -510,8 +537,8 @@ class _ServiceChip extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - if (healthStatus != null) ...[ - _ServiceHealthDot(status: healthStatus!), + if (healthServiceId != null) ...[ + _ServiceHealthDot(serviceId: healthServiceId!), const SizedBox(width: 8), ], if (iconPath != null) ...[ @@ -521,6 +548,10 @@ class _ServiceChip extends StatelessWidget { File(iconPath!), width: 18, height: 18, + cacheWidth: (18 * MediaQuery.devicePixelRatioOf(context)) + .ceil(), + cacheHeight: (18 * MediaQuery.devicePixelRatioOf(context)) + .ceil(), fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => Icon( Icons.extension, @@ -549,13 +580,20 @@ class _ServiceChip extends StatelessWidget { } } -class _ServiceHealthDot extends StatelessWidget { - final String status; +class _ServiceHealthDot extends ConsumerWidget { + final String serviceId; - const _ServiceHealthDot({required this.status}); + const _ServiceHealthDot({required this.serviceId}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final status = + ref.watch( + extensionProvider.select( + (state) => state.healthStatuses[serviceId]?.status, + ), + ) ?? + 'unknown'; final color = _serviceHealthColor(status); return Tooltip( message: _serviceHealthTooltip(context, status), @@ -670,6 +708,8 @@ class _TrackInfoHeader extends StatefulWidget { class _TrackInfoHeaderState extends State<_TrackInfoHeader> { bool _expanded = false; bool _isOverflowing = false; + (double, TextStyle?, TextScaler, TextDirection, Locale?)? _titleLayout; + bool _titleOverflows = false; @override Widget build(BuildContext context) { @@ -697,6 +737,12 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> { widget.coverUrl!, width: 56, height: 56, + cacheWidth: + (56 * MediaQuery.devicePixelRatioOf(context)) + .ceil(), + cacheHeight: + (56 * MediaQuery.devicePixelRatioOf(context)) + .ceil(), fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => Container( @@ -727,22 +773,35 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> { .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.w600); - final titleSpan = TextSpan( - text: widget.trackName, - style: titleStyle, + final titleLayout = ( + constraints.maxWidth, + titleStyle, + MediaQuery.textScalerOf(context), + Directionality.of(context), + Localizations.maybeLocaleOf(context), ); - final titlePainter = TextPainter( - text: titleSpan, - maxLines: 1, - textDirection: TextDirection.ltr, - )..layout(maxWidth: constraints.maxWidth); - final titleOverflows = titlePainter.didExceedMaxLines; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _isOverflowing != titleOverflows) { - setState(() => _isOverflowing = titleOverflows); - } - }); + if (_titleLayout != titleLayout) { + _titleLayout = titleLayout; + final painter = TextPainter( + text: TextSpan( + text: widget.trackName, + style: titleStyle, + ), + maxLines: 1, + textScaler: titleLayout.$3, + textDirection: titleLayout.$4, + locale: titleLayout.$5, + )..layout(maxWidth: constraints.maxWidth); + _titleOverflows = painter.didExceedMaxLines; + painter.dispose(); + } + if (_isOverflowing != _titleOverflows) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _isOverflowing != _titleOverflows) { + setState(() => _isOverflowing = _titleOverflows); + } + }); + } return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -788,4 +847,13 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> { ), ); } + + @override + void didUpdateWidget(covariant _TrackInfoHeader oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.trackName != widget.trackName) { + _titleLayout = null; + _expanded = false; + } + } } diff --git a/test/download_service_picker_drag_test.dart b/test/download_service_picker_drag_test.dart index 56a6b3a4..38179e8f 100644 --- a/test/download_service_picker_drag_test.dart +++ b/test/download_service_picker_drag_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; @@ -9,32 +10,81 @@ import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; class _Extensions extends ExtensionNotifier { - _Extensions(this._qualityCount); + _Extensions( + this._qualityCount, { + this.serviceCount = 1, + this.hasHealth = false, + }); final int _qualityCount; + final int serviceCount; + final bool hasHealth; + final healthRequests = []; @override ExtensionState build() => ExtensionState( extensions: [ - Extension( - id: 'example', - name: 'example', - displayName: 'Example', - version: '1.0.0', - description: '', - enabled: true, - status: 'loaded', - hasDownloadProvider: true, - qualityOptions: List.generate( - _qualityCount, - (index) => QualityOption(id: '$index', label: 'Quality $index'), + for (var service = 0; service < serviceCount; service++) + Extension( + id: service == 0 ? 'example' : 'example-$service', + name: 'example', + displayName: service == 0 ? 'Example' : 'Source $service', + version: '1.0.0', + description: '', + enabled: true, + status: 'loaded', + hasDownloadProvider: true, + serviceHealth: hasHealth + ? const [ + ExtensionServiceHealthCheck( + id: 'health', + url: 'https://example.com/health', + ), + ] + : const [], + qualityOptions: List.generate( + _qualityCount, + (index) => QualityOption(id: '$index', label: 'Quality $index'), + ), + ), + if (hasHealth) + const Extension( + id: 'metadata-only', + name: 'metadata-only', + displayName: 'Metadata source', + version: '1.0.0', + description: '', + enabled: true, + status: 'loaded', + hasMetadataProvider: true, + serviceHealth: [ + ExtensionServiceHealthCheck( + id: 'health', + url: 'https://example.com/health', + ), + ], ), - ), ], ); @override void refreshEnabledExtensionHealth({bool force = false}) {} + + void publishHealth(String id, String status) => state = state.copyWith( + healthStatuses: { + ...state.healthStatuses, + id: ExtensionHealthStatus(extensionId: id, status: status), + }, + ); + + @override + Future checkExtensionHealth( + String extensionId, { + bool force = false, + }) async { + healthRequests.add(extensionId); + return state.healthStatuses[extensionId]; + } } class _Settings extends SettingsNotifier { @@ -126,4 +176,84 @@ void main() { }, ); } + + for (final brightness in Brightness.values) { + testWidgets( + 'source status stays local and selection keeps chip geometry in $brightness', + (tester) async { + tester.view.physicalSize = const Size(393, 852); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final extensions = _Extensions(3, serviceCount: 2, hasHealth: true); + (String, String)? selection; + await tester.pumpWidget( + ProviderScope( + overrides: [ + extensionProvider.overrideWith(() => extensions), + settingsProvider.overrideWith(_Settings.new), + ], + child: MaterialApp( + theme: MornyeTheme.build(brightness), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => DownloadServicePicker.show( + context, + trackName: 'Example track', + onSelect: (quality, service) => + selection = (quality, service), + ), + child: const Text('Open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + final qualityBefore = tester.widget(find.text('Quality 0')); + expect(extensions.healthRequests, ['example', 'example-1']); + final titleBefore = tester.widget(find.text('Example track')); + final source1 = find.ancestor( + of: find.text('Example'), + matching: find.byType(CupertinoButton), + ); + final source2 = find.ancestor( + of: find.text('Source 1'), + matching: find.byType(CupertinoButton), + ); + final sourceRects = [tester.getRect(source1), tester.getRect(source2)]; + extensions.publishHealth('example', 'offline'); + await tester.pumpAndSettle(); + expect(find.byTooltip('Service offline'), findsOneWidget); + expect( + identical(tester.widget(find.text('Quality 0')), qualityBefore), + isTrue, + ); + expect( + identical(tester.widget(find.text('Example track')), titleBefore), + isTrue, + ); + expect([tester.getRect(source1), tester.getRect(source2)], sourceRects); + await tester.tap(find.text('Example')); + await tester.pumpAndSettle(); + expect( + identical(tester.widget(find.text('Quality 0')), qualityBefore), + isTrue, + ); + expect(extensions.healthRequests, ['example', 'example-1']); + await tester.tap(find.text('Source 1')); + await tester.pumpAndSettle(); + expect([tester.getRect(source1), tester.getRect(source2)], sourceRects); + await tester.tap(find.text('Quality 1')); + await tester.pumpAndSettle(); + expect(selection, ('1', 'example-1')); + expect(find.byType(DownloadServicePicker), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } }