import 'dart:ui' show Tristate; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/tema/pluriwave_theme.dart'; import 'package:pluriwave/tema/pluriwave_tokens.dart'; import 'package:pluriwave/widgets/mini_reproductor.dart'; import 'package:pluriwave/widgets/pluri_bottom_navigation.dart'; import 'package:pluriwave/widgets/pluri_icon.dart'; import 'package:pluriwave/widgets/pluri_layout.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fakes.dart'; /// "Barra globo" bottom navigation — Design turn t4, option 4a. Spec /// transcribed verbatim from `PluriWave Rediseno.dc.html` lines 91-99: a /// solid capsule bar with a taller balloon capsule that slides beneath /// whichever tab is active. void main() { setUp(() { SharedPreferences.setMockInitialValues({}); }); const items = [ PluriNavItem(glyph: PluriIconGlyph.home, label: 'Escuchar'), PluriNavItem(glyph: PluriIconGlyph.search, label: 'Buscar'), PluriNavItem(glyph: PluriIconGlyph.favorites, label: 'Favoritos'), PluriNavItem(glyph: PluriIconGlyph.alarm, label: 'Alarmas'), PluriNavItem(glyph: PluriIconGlyph.settings, label: 'Ajustes'), ]; Widget hostFor(int selectedIndex, {ValueChanged? onSelected}) { return MaterialApp( theme: PluriWaveTheme.dark(), locale: const Locale('es'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: SizedBox( width: 390, child: PluriBottomNavigation( items: items, selectedIndex: selectedIndex, onSelected: onSelected ?? (_) {}, ), ), ), ); } testWidgets('only the active tab renders a visible text label', ( tester, ) async { await tester.pumpWidget(hostFor(1)); await tester.pump(); expect(find.text('Buscar'), findsOneWidget); expect(find.text('Escuchar'), findsNothing); expect(find.text('Favoritos'), findsNothing); expect(find.text('Alarmas'), findsNothing); expect(find.text('Ajustes'), findsNothing); }); testWidgets('active icon and label use the brand colour (#21D4D9)', ( tester, ) async { await tester.pumpWidget(hostFor(2)); await tester.pump(); final activeIcon = tester.widget( find.descendant( of: find.byKey(PluriBottomNavigation.itemKey(2)), matching: find.byType(PluriIcon), ), ); expect(activeIcon.color, PluriWaveTokens.brand); final label = tester.widget(find.text('Favoritos')); expect(label.style?.color, PluriWaveTokens.brand); }); testWidgets('inactive icons are dimmed to 46% and carry no colour override', ( tester, ) async { await tester.pumpWidget(hostFor(0)); await tester.pump(); final inactiveIcon = tester.widget( find.descendant( of: find.byKey(PluriBottomNavigation.itemKey(1)), matching: find.byType(PluriIcon), ), ); expect(inactiveIcon.color, isNull); final dimmed = tester.widget( find.ancestor( of: find.descendant( of: find.byKey(PluriBottomNavigation.itemKey(1)), matching: find.byType(PluriIcon), ), matching: find.byType(Opacity), ), ); expect(dimmed.opacity, closeTo(0.46, 0.001)); }); testWidgets( 'the balloon exists, sized per spec, and centred over the active tab', (tester) async { await tester.pumpWidget(hostFor(0)); await tester.pump(); final balloon = find.byKey(PluriBottomNavigation.balloonKey); expect(balloon, findsOneWidget); expect(tester.getSize(balloon), const Size(110, 74)); final balloonCenterX = tester.getCenter(balloon).dx; final tabCenterX = tester.getCenter(find.byKey(PluriBottomNavigation.itemKey(0))).dx; expect(balloonCenterX, closeTo(tabCenterX, 1)); final decoratedBox = tester.widget( find.descendant(of: balloon, matching: find.byType(DecoratedBox)), ); final decoration = decoratedBox.decoration as BoxDecoration; expect(decoration.color, PluriWaveTokens.dark.balloonSurface); }, ); testWidgets('selection changes slide the balloon to the newly active tab', ( tester, ) async { var selected = 0; await tester.pumpWidget( StatefulBuilder( builder: (context, setState) => MaterialApp( theme: PluriWaveTheme.dark(), locale: const Locale('es'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: SizedBox( width: 390, child: PluriBottomNavigation( items: items, selectedIndex: selected, onSelected: (i) => setState(() => selected = i), ), ), ), ), ), ); await tester.pump(); final initialX = tester.getCenter(find.byKey(PluriBottomNavigation.balloonKey)).dx; await tester.tap(find.byKey(PluriBottomNavigation.itemKey(3))); await tester.pump(); await tester.pump(const Duration(milliseconds: 260)); final finalX = tester.getCenter(find.byKey(PluriBottomNavigation.balloonKey)).dx; expect(finalX, isNot(closeTo(initialX, 1))); expect( finalX, closeTo( tester.getCenter(find.byKey(PluriBottomNavigation.itemKey(3))).dx, 1, ), ); }); testWidgets('inactive tabs keep their semantic label without visible text', ( tester, ) async { final semantics = tester.ensureSemantics(); await tester.pumpWidget(hostFor(0)); await tester.pump(); final node = tester.getSemantics( find.byKey(PluriBottomNavigation.itemKey(3)), ); expect(node.label, 'Alarmas'); expect(node.flagsCollection.isButton, isTrue); expect(node.flagsCollection.isSelected, Tristate.isFalse); semantics.dispose(); }); testWidgets('every tab meets the 48x48dp minimum tap target', (tester) async { await tester.pumpWidget(hostFor(0)); await tester.pump(); for (var i = 0; i < items.length; i++) { final size = tester.getSize(find.byKey(PluriBottomNavigation.itemKey(i))); expect(size.width, greaterThanOrEqualTo(48)); expect(size.height, greaterThanOrEqualTo(48)); } }); testWidgets('PluriBottomNavigation.altura matches its real laid-out height', ( tester, ) async { await tester.pumpWidget(hostFor(0)); await tester.pump(); final real = tester.getSize(find.byType(PluriBottomNavigation)).height; expect(PluriBottomNavigation.altura, closeTo(real, 0.5)); }); testWidgets( 'PluriLayout.bottomChromeInset matches the real composed chrome height ' '(MiniReproductor + PluriBottomNavigation + the safe-area gap)', (tester) async { final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), iniciarAutomaticamente: false, ); addTearDown(estado.dispose); await estado.reproducir(emisoraDemo(uuid: 'a', nombre: 'Station A')); const chromeKey = Key('chromeSafeArea'); await tester.pumpWidget( ChangeNotifierProvider.value( value: estado, child: MaterialApp( theme: PluriWaveTheme.dark(), locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: const SizedBox.shrink(), bottomNavigationBar: SafeArea( key: chromeKey, top: false, minimum: const EdgeInsets.only(bottom: PluriLayout.compactGap), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Mirrors app.dart's real composition: item 22 / audit // 3.6 made MiniReproductor full-bleed, so only // PluriBottomNavigation keeps the 8px side margin. const MiniReproductor(), Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), child: PluriBottomNavigation( items: items, selectedIndex: 0, onSelected: (_) {}, ), ), ], ), ), ), ), ), ); await tester.pump(); final medido = tester.getSize(find.byKey(chromeKey)).height; expect( PluriLayout.bottomChromeInset, closeTo(medido, 4), reason: 'bottomChromeInset must be derived from the real composed chrome ' 'height (MiniReproductor + PluriBottomNavigation + the ' 'safe-area gap), not guessed — if this fails, re-measure and ' 'update the formula in pluri_layout.dart', ); }, ); }