import 'dart:ui' as ui; import 'package:flutter/cupertino.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'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; void main() { setUp(() => SharedPreferences.setMockInitialValues({})); Future openMenu( WidgetTester tester, { required Rect anchor, required ValueChanged onResult, Size size = const Size(390, 844), Brightness brightness = Brightness.light, double textScale = 1, bool reduceMotion = false, bool preferAbove = false, bool highContrast = false, bool lowEnd = false, Color? backgroundColor, GlobalKey? capture, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.pumpWidget( ProviderScope( overrides: [ lowEndDeviceProvider.overrideWithValue(lowEnd), backdropBlurEnabledProvider.overrideWithValue(false), ], child: MaterialApp( theme: MornyeTheme.build(brightness), builder: (context, child) => RepaintBoundary( key: capture, child: MediaQuery( data: MediaQuery.of(context).copyWith( padding: const EdgeInsets.fromLTRB(0, 24, 0, 24), textScaler: TextScaler.linear(textScale), disableAnimations: reduceMotion, highContrast: highContrast, ), child: child!, ), ), home: Scaffold( backgroundColor: backgroundColor, body: Builder( builder: (context) => Center( child: TextButton( onPressed: () async { final result = await showMornyeContextMenu( context: context, anchor: anchor, preferAbove: preferAbove, builder: (menuContext) { MornyeMenuAction action(String label, IconData icon) => MornyeMenuAction( icon: icon, label: label, onPressed: () => Navigator.of(menuContext).pop(label), ); return MornyeContextMenu( quickActions: [ action( 'Download', CupertinoIcons.arrow_down_circle, ), action('Favorite', CupertinoIcons.star), action('Share', CupertinoIcons.share), ], groups: [ [ action('Play', CupertinoIcons.play), action('Shuffle', CupertinoIcons.shuffle), ], [ action( 'Add to playlist', CupertinoIcons.text_badge_plus, ), action('Play next', CupertinoIcons.text_insert), ], [action('Remove', CupertinoIcons.trash)], ], ); }, ); onResult(result); }, child: const Text('Open'), ), ), ), ), ), ), ); await tester.tap(find.text('Open')); await tester.pumpAndSettle(); } Future> samplePixels( WidgetTester tester, GlobalKey capture, List positions, ) async => (await tester.runAsync(() async { final boundary = tester.renderObject( find.byKey(capture), ); final image = await boundary.toImage(); try { final pixels = (await image.toByteData( format: ui.ImageByteFormat.rawRgba, ))!; return positions.map((position) { final local = boundary.globalToLocal(position); final index = (local.dy.floor() * image.width + local.dx.floor()) * 4; return Color.fromARGB( pixels.getUint8(index + 3), pixels.getUint8(index), pixels.getUint8(index + 1), pixels.getUint8(index + 2), ); }).toList(); } finally { image.dispose(); } }))!; for (final brightness in Brightness.values) { for (final background in [ Colors.white, Colors.black, const Color(0xff6c3a22), ]) { testWidgets('menu labels stay readable over $background ($brightness)', ( tester, ) async { final originalDisableShadows = debugDisableShadows; debugDisableShadows = false; try { final capture = GlobalKey(); await openMenu( tester, anchor: const Rect.fromLTWH(330, 144, 44, 44), brightness: brightness, backgroundColor: background, capture: capture, onResult: (_) {}, ); final menu = tester.getRect(find.byType(MornyeContextMenu)); final pixels = await samplePixels(tester, capture, [ for (final x in [menu.left + 8, menu.right - 8]) for (final fraction in [0.25, 0.5, 0.75]) Offset(x, menu.top + menu.height * fraction), ]); final foreground = MornyeTheme.build( brightness, ).colorScheme.onSurface; for (final inside in pixels) { // Busy lyrics and bright artwork must not wash out either label. for (final textColor in [ foreground, Color.alphaBlend(foreground.withValues(alpha: 0.96), inside), ]) { final luminances = [ inside.computeLuminance(), textColor.computeLuminance(), ]..sort(); final contrast = (luminances.last + 0.05) / (luminances.first + 0.05); expect(contrast, greaterThanOrEqualTo(4.5)); } // The panel remains even at both edges without an offset lens. expect(inside.r, closeTo(pixels.first.r, 0.015)); expect(inside.g, closeTo(pixels.first.g, 0.015)); expect(inside.b, closeTo(pixels.first.b, 0.015)); } expect(tester.takeException(), isNull); } finally { debugDisableShadows = originalDisableShadows; } }); } testWidgets('menu stays on screen near a bottom edge ($brightness)', ( tester, ) async { String? result; const anchor = Rect.fromLTWH(338, 728, 36, 44); await openMenu( tester, anchor: anchor, brightness: brightness, onResult: (value) => result = value, ); final rect = tester.getRect(find.byType(MornyeContextMenu)); expect(rect.left, greaterThanOrEqualTo(16)); expect(rect.right, lessThanOrEqualTo(374)); expect(rect.top, greaterThanOrEqualTo(40)); expect(rect.bottom, lessThan(anchor.top)); expect(find.text('Share').hitTestable(), findsOneWidget); await tester.tap(find.text('Share')); await tester.pumpAndSettle(); expect(result, 'Share'); expect(find.byType(MornyeContextMenu), findsNothing); expect(tester.takeException(), isNull); }); } testWidgets('frosted glass still carries the artwork color', (tester) async { final colors = []; for (final background in [ const Color(0xff804020), const Color(0xff204080), ]) { final capture = GlobalKey(); await openMenu( tester, anchor: const Rect.fromLTWH(330, 144, 44, 44), brightness: Brightness.dark, backgroundColor: background, capture: capture, onResult: (_) {}, ); final rect = tester.getRect(find.byType(MornyeContextMenu)); colors.addAll( await samplePixels(tester, capture, [ Offset(rect.left + 8, rect.center.dy), ]), ); await tester.pumpWidget(const SizedBox.shrink()); } expect(colors.first.r, greaterThan(colors.last.r + 0.03)); expect(colors.last.b, greaterThan(colors.first.b + 0.03)); expect(tester.takeException(), isNull); }); for (final highContrast in [false, true]) { testWidgets('menu stays readable without blur (contrast: $highContrast)', ( tester, ) async { final capture = GlobalKey(); await openMenu( tester, anchor: const Rect.fromLTWH(330, 144, 44, 44), brightness: Brightness.dark, backgroundColor: const Color(0xff6c3a22), highContrast: highContrast, lowEnd: !highContrast, capture: capture, onResult: (_) {}, ); final menu = find.byType(MornyeContextMenu); final rect = tester.getRect(menu); final pixels = await samplePixels(tester, capture, [ Offset(rect.left + 8, rect.center.dy), ]); final theme = MornyeTheme.build(Brightness.dark); expect(pixels.single, theme.colorScheme.surfaceContainerHigh); expect( find.descendant(of: menu, matching: find.byType(BackdropFilter)), findsNothing, ); await tester.tap(find.text('Share')); await tester.pumpAndSettle(); expect(menu, findsNothing); expect(tester.takeException(), isNull); }); } testWidgets( 'large text in landscape keeps shortcuts and last action usable', (tester) async { String? result; await openMenu( tester, anchor: const Rect.fromLTWH(600, 200, 24, 44), size: const Size(640, 320), textScale: 2.5, preferAbove: true, onResult: (value) => result = value, ); final rect = tester.getRect(find.byType(MornyeContextMenu)); expect(rect.top, greaterThanOrEqualTo(40)); expect(rect.bottom, lessThanOrEqualTo(280)); expect(find.text('Download').hitTestable(), findsOneWidget); await tester.ensureVisible(find.text('Remove')); await tester.pumpAndSettle(); await tester.tap(find.text('Remove')); await tester.pumpAndSettle(); expect(result, 'Remove'); expect(tester.takeException(), isNull); }, ); testWidgets('outside tap dismisses without selecting in reduced motion', ( tester, ) async { final results = []; await openMenu( tester, anchor: const Rect.fromLTWH(12, 40, 44, 44), reduceMotion: true, preferAbove: true, onResult: results.add, ); expect( tester.getRect(find.byType(MornyeContextMenu)).top, greaterThanOrEqualTo(84), ); await tester.tapAt(const Offset(8, 800)); await tester.pump(); expect(results, [null]); expect(find.byType(MornyeContextMenu), findsNothing); expect(tester.takeException(), isNull); }); }