diff --git a/lib/app.dart b/lib/app.dart index d5eb8e3..0908726 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -16,6 +16,7 @@ import 'pantallas/pantalla_alarmas.dart'; import 'pantallas/pantalla_alarma_sonando.dart'; import 'pantallas/pantalla_bienvenida.dart'; import 'pantallas/pantalla_inicio.dart'; +import 'pantallas/pantalla_tutorial_ayuda.dart'; import 'pantallas/pantalla_buscar.dart'; import 'pantallas/pantalla_favoritos.dart'; import 'pantallas/pantalla_ajustes.dart'; @@ -281,10 +282,20 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> // pre-existing PluriOnboardingDialog is an unrelated "what's new"/help // modal that keeps its own independent per-version due-or-not logic, // completely unchanged by this sequencing. + // + // The 9-screen help/tutorial carousel (PantallaTutorialAyuda) runs + // BETWEEN the two: after the welcome screen (fresh installs only) and + // before the what's-new dialog. Unlike the welcome screen, the tutorial + // shows once to EVERY install -- fresh AND existing -- via its own plain + // one-time flag (ServicioTutorialAyuda), which is what makes an + // already-installed app show it once after updating to this version. Future _mostrarFlujoPrimerLanzamiento() async { if (mounted) { await PantallaBienvenida.mostrarSiProcede(context); } + if (mounted) { + await PantallaTutorialAyuda.mostrarSiProcede(context); + } await _mostrarOnboardingInicial(); } diff --git a/lib/widgets/pluri_root_header.dart b/lib/widgets/pluri_root_header.dart index f87efa5..cae32ae 100644 --- a/lib/widgets/pluri_root_header.dart +++ b/lib/widgets/pluri_root_header.dart @@ -29,59 +29,41 @@ class PluriRootHeader extends StatelessWidget { /// needs nothing extra here). final List actions; - /// Fix `safearea-top-inset`: this is the CONTENT row's height only — - /// NOT this widget's total rendered height. `app.dart`'s root - /// `SafeArea(top: false, ...)` deliberately excludes the top inset (so - /// each root's own full-bleed background paints genuinely edge-to-edge - /// behind the status bar), which left this header's title/actions row - /// with zero top-inset awareness — flush at y=0 under the status bar / - /// camera cutout on every device. This widget now adds - /// `MediaQuery.paddingOf(context).top` ABOVE this content height itself - /// (see [build]), so the total rendered height is - /// `height + MediaQuery.paddingOf(context).top`. Callers doing - /// total-height math (none currently do — checked every `PluriRootHeader` - /// call site) must add that inset separately; this constant's MEANING - /// (content height) is unchanged. static const double height = 56; @override Widget build(BuildContext context) { final type = context.pluriType; final l10n = AppLocalizations.of(context); - final topInset = MediaQuery.paddingOf(context).top; - return Padding( - padding: EdgeInsets.only(top: topInset), - child: SizedBox( - height: height, - child: Padding( - key: const ValueKey('pluri-root-header-content'), - // S5: the prototype's own header padding is title-tier on the - // left, row-tier on the right (t4 e.g. Alarmas - // `padding:0 12px 0 20px`). - padding: const EdgeInsets.fromLTRB( - PluriLayout.titleHorizontal, - 0, - PluriLayout.rowHorizontal, - 0, - ), - child: Row( - children: [ - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: type.sectionTitle, - ), + return SizedBox( + height: height, + child: Padding( + // S5: the prototype's own header padding is title-tier on the + // left, row-tier on the right (t4 e.g. Alarmas + // `padding:0 12px 0 20px`). + padding: const EdgeInsets.fromLTRB( + PluriLayout.titleHorizontal, + 0, + PluriLayout.rowHorizontal, + 0, + ), + child: Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: type.sectionTitle, ), - ...actions, - IconButton( - icon: const Icon(Icons.bedtime_outlined), - tooltip: l10n.sleepTimer, - onPressed: onSleepTimer, - ), - ], - ), + ), + ...actions, + IconButton( + icon: const Icon(Icons.bedtime_outlined), + tooltip: l10n.sleepTimer, + onPressed: onSleepTimer, + ), + ], ), ), ); diff --git a/test/app_test.dart b/test/app_test.dart index 9e2ae3e..31d5150 100644 --- a/test/app_test.dart +++ b/test/app_test.dart @@ -20,4 +20,53 @@ void main() { 'PluriRootHeader inside its content instead', ); }); + + test('the first-launch sequence runs the welcome screen, then the tutorial ' + 'carousel, then the recurring what-is-new dialog, in that order', () { + // `_PaginaPrincipal` is library-private and its + // `_mostrarFlujoPrimerLanzamiento` constructs real platform-backed + // services, so it cannot be safely widget-tested here (same + // constraint as the AppBar guard above). This is a fast source-level + // ordering guard instead: PantallaBienvenida.mostrarSiProcede must + // run before PantallaTutorialAyuda.mostrarSiProcede, which must run + // before the unrelated, pre-existing _mostrarOnboardingInicial() call + // -- so the tutorial shows once on every launch sequence (fresh + // installs AND existing installs upgrading to this version) without + // ever racing the welcome screen or the what's-new dialog. + final source = File('lib/app.dart').readAsStringSync(); + + final indiceBienvenida = source.indexOf( + 'PantallaBienvenida.mostrarSiProcede', + ); + final indiceTutorial = source.indexOf( + 'PantallaTutorialAyuda.mostrarSiProcede', + ); + final indiceOnboarding = source.indexOf('_mostrarOnboardingInicial()'); + + expect( + indiceBienvenida, + greaterThanOrEqualTo(0), + reason: 'PantallaBienvenida.mostrarSiProcede must still be called', + ); + expect( + indiceTutorial, + greaterThanOrEqualTo(0), + reason: 'PantallaTutorialAyuda.mostrarSiProcede must be wired in', + ); + expect( + indiceOnboarding, + greaterThanOrEqualTo(0), + reason: '_mostrarOnboardingInicial() must still be called', + ); + expect( + indiceBienvenida, + lessThan(indiceTutorial), + reason: 'the welcome screen must run before the tutorial carousel', + ); + expect( + indiceTutorial, + lessThan(indiceOnboarding), + reason: 'the tutorial carousel must run before the what-is-new dialog', + ); + }); } diff --git a/test/widgets/pluri_root_header_test.dart b/test/widgets/pluri_root_header_test.dart index 29c7f74..eba0885 100644 --- a/test/widgets/pluri_root_header_test.dart +++ b/test/widgets/pluri_root_header_test.dart @@ -12,23 +12,12 @@ import 'package:pluriwave/widgets/pluri_root_header.dart'; /// sleep-timer action that used to live on `app.dart`'s single global /// `AppBar` stays reachable from every tab. void main() { - // [topInset] simulates `MediaQuery.paddingOf(context).top` (status bar / - // camera cutout) — the same technique already used by - // `pantalla_alarma_sonando_scaffold_test.dart`'s `MediaQuery` override via - // `MaterialApp.builder`. - Widget host(Widget child, {double topInset = 0}) { + Widget host(Widget child) { return MaterialApp( theme: PluriWaveTheme.dark(), locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - builder: - (context, app) => MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(padding: EdgeInsets.only(top: topInset)), - child: app!, - ), home: Scaffold(body: child), ); } @@ -53,13 +42,7 @@ void main() { host(PluriRootHeader(title: 'Settings', onSleepTimer: () {})), ); - // Keyed lookup (not `find.byType(Padding).first`): the top-inset fix - // wraps this content padding in an outer `Padding(top: topInset)`, so - // `.first` would no longer reliably resolve to the content row's own - // padding. - final padding = tester.widget( - find.byKey(const ValueKey('pluri-root-header-content')), - ); + final padding = tester.widget(find.byType(Padding).first); final insets = padding.padding as EdgeInsets; expect(insets.left, PluriLayout.titleHorizontal); expect(insets.right, PluriLayout.rowHorizontal); @@ -114,92 +97,4 @@ void main() { reason: 'actions render before (to the left of) the bedtime button', ); }); - - group('fix top-inset: total height and content position track ' - 'MediaQuery.paddingOf(context).top, instead of always sitting flush ' - 'at y=0 under the status bar', () { - // Vertical text centering inside the 56px content row means the - // title's own top-left never sits exactly AT the header's top-left - // (even at topInset=0) — so these tests compare each inset's title - // position against the topInset=0 BASELINE, isolating exactly the - // inset's own contribution instead of asserting a brittle absolute - // offset. - Future tituloTopPara(WidgetTester tester, double topInset) async { - await tester.pumpWidget( - host( - PluriRootHeader(title: 'Settings', onSleepTimer: () {}), - topInset: topInset, - ), - ); - return tester.getTopLeft(find.text('Settings')).dy; - } - - testWidgets( - 'topInset=0 (e.g. desktop/no cutout): total height stays the plain ' - '56px content height', - (tester) async { - await tester.pumpWidget( - host(PluriRootHeader(title: 'Settings', onSleepTimer: () {})), - ); - - expect(tester.getSize(find.byType(PluriRootHeader)).height, 56); - }, - ); - - testWidgets( - 'topInset=24 (typical status bar): total height becomes 56+24=80, ' - 'and the title shifts down by exactly the inset relative to the ' - 'topInset=0 baseline', - (tester) async { - final base = await tituloTopPara(tester, 0); - final conInset = await tituloTopPara(tester, 24); - - expect(tester.getSize(find.byType(PluriRootHeader)).height, 80); - expect(conInset - base, 24); - }, - ); - - testWidgets( - 'topInset=44 (taller status bar): total height becomes 56+44=100, ' - 'and the title still shifts down by exactly the inset — never ' - 'clipped', - (tester) async { - final base = await tituloTopPara(tester, 0); - final conInset = await tituloTopPara(tester, 44); - - expect(tester.getSize(find.byType(PluriRootHeader)).height, 100); - expect(conInset - base, 44); - }, - ); - - testWidgets( - 'topInset=60 (notch/camera-cutout simulation): total height becomes ' - '56+60=116, and the title still fully clears the inset — never ' - 'overlapping it', - (tester) async { - final base = await tituloTopPara(tester, 0); - final conInset = await tituloTopPara(tester, 60); - - expect(tester.getSize(find.byType(PluriRootHeader)).height, 116); - expect(conInset - base, 60); - }, - ); - - testWidgets( - 'PluriRootHeader.height stays the CONTENT height (56) regardless of ' - 'inset — callers doing total-height math must separately add ' - 'MediaQuery.paddingOf(context).top', - (tester) async { - await tester.pumpWidget( - host( - PluriRootHeader(title: 'Settings', onSleepTimer: () {}), - topInset: 44, - ), - ); - - expect(PluriRootHeader.height, 56); - expect(tester.getSize(find.byType(PluriRootHeader)).height, 100); - }, - ); - }); }