feat(tutorial): add 9-screen help/tutorial carousel
Add PantallaTutorialAyuda, a PageView-based carousel covering saved
stations/groups, per-station equalizer, recording, adaptive alarms,
Android Auto favorites, auto-reconnect, snooze duration, custom
stations, and a closing summary with a "watch it again" reminder.
ServicioTutorialAyuda persists a one-time seen flag so the carousel
shows once via mostrarSiProcede, independent of entry point; the
final page's CTA label depends on the primerArranque constructor
parameter ("Empezar a escuchar" vs "Cerrar").
Translate the new copy into all 13 supported locales and update
helpSubtitle to describe the new entry point.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_tutorial_ayuda.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// `PantallaTutorialAyuda.mostrarSiProcede` wires the 9-screen help/tutorial
|
||||
/// carousel into the genuine first-launch flow (`app.dart`), between the
|
||||
/// welcome screen and the recurring "what's new" dialog. This must show
|
||||
/// once -- to both a genuinely fresh install AND an existing install that
|
||||
/// already has other, unrelated flags persisted (e.g. the welcome screen
|
||||
/// already marked seen) -- and never again after that.
|
||||
Widget _appConDisparador(GlobalKey<NavigatorState> navigatorKey) {
|
||||
return MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder:
|
||||
(context) => Scaffold(
|
||||
body: Center(
|
||||
child: ElevatedButton(
|
||||
onPressed:
|
||||
() => PantallaTutorialAyuda.mostrarSiProcede(context),
|
||||
child: const Text('disparar'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets(
|
||||
'first launch (no seen flag persisted) shows the tutorial carousel',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
await tester.pumpWidget(_appConDisparador(navigatorKey));
|
||||
await tester.tap(find.text('disparar'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaTutorialAyuda), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('an existing install with unrelated flags already set (e.g. the '
|
||||
'welcome screen already seen) but never this one still shows it', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({'pluri_bienvenida_vista_v1': true});
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
await tester.pumpWidget(_appConDisparador(navigatorKey));
|
||||
await tester.tap(find.text('disparar'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaTutorialAyuda), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'second launch (seen flag already persisted) does not show it again',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({'pluri_tutorial_visto_v1': true});
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
await tester.pumpWidget(_appConDisparador(navigatorKey));
|
||||
await tester.tap(find.text('disparar'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaTutorialAyuda), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('showing it once persists the flag so a later check in the same '
|
||||
'session skips it', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
await tester.pumpWidget(_appConDisparador(navigatorKey));
|
||||
await tester.tap(find.text('disparar'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PantallaTutorialAyuda), findsOneWidget);
|
||||
|
||||
// Dismiss it the same way "Saltar"/the last CTA does (a plain pop),
|
||||
// simulating the carousel being resolved before the next check happens.
|
||||
navigatorKey.currentState!.pop();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('disparar'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PantallaTutorialAyuda), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_tutorial_ayuda.dart';
|
||||
|
||||
/// The 9-screen help/tutorial carousel (`PantallaTutorialAyuda`). Content
|
||||
/// spec: mockup screens 5b..5h (reading order 1..9), each with an icon
|
||||
/// badge, a headline, a body, a 9-dot progress indicator, a "Siguiente"
|
||||
/// (Next) button, and -- on every page except the last -- a "Saltar"
|
||||
/// (Skip) affordance. The last page's CTA reads "Empezar a escuchar" on a
|
||||
/// first-launch entry, or "Cerrar" when reached manually from Ajustes
|
||||
/// (`primerArranque` constructor param), and it drops "Saltar" entirely.
|
||||
Widget _app({required bool primerArranque}) {
|
||||
return MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: PantallaTutorialAyuda(primerArranque: primerArranque),
|
||||
);
|
||||
}
|
||||
|
||||
/// The 9 expected headlines, in the mandated reading order (mockup ids
|
||||
/// 5b, 5c, 5d, 5e, 5f, 5g, 5h1, 5h2, 5h).
|
||||
List<String> _headlinesEsperados(AppLocalizations l10n) => [
|
||||
l10n.tutorialPage1Headline,
|
||||
l10n.tutorialPage2Headline,
|
||||
l10n.tutorialPage3Headline,
|
||||
l10n.tutorialPage4Headline,
|
||||
l10n.tutorialPage5Headline,
|
||||
l10n.tutorialPage6Headline,
|
||||
l10n.tutorialPage7Headline,
|
||||
l10n.tutorialPage8Headline,
|
||||
l10n.tutorialPage9Headline,
|
||||
];
|
||||
|
||||
void main() {
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
|
||||
testWidgets('renders exactly 9 pages, one dot per page', (tester) async {
|
||||
await tester.pumpWidget(_app(primerArranque: true));
|
||||
await tester.pump();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
expect(pageView.controller!.hasClients, isTrue);
|
||||
|
||||
expect(
|
||||
find.byType(PuntoIndicadorTutorial),
|
||||
findsNWidgets(9),
|
||||
reason: 'exactly 9 dots, one per page',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'the 9 pages carry the right headlines, in the mandated reading order',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_app(primerArranque: true));
|
||||
await tester.pump();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
final controller = pageView.controller!;
|
||||
final esperados = _headlinesEsperados(l10n);
|
||||
|
||||
for (var i = 0; i < esperados.length; i++) {
|
||||
controller.jumpToPage(i);
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.text(esperados[i]),
|
||||
findsOneWidget,
|
||||
reason: 'page $i should show "${esperados[i]}"',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('Saltar pops immediately from the first page', (tester) async {
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: Center(child: Text('home stand-in'))),
|
||||
),
|
||||
);
|
||||
|
||||
unawaited(
|
||||
navigatorKey.currentState!.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const PantallaTutorialAyuda(primerArranque: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PantallaTutorialAyuda), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(l10n.tutorialSkipAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaTutorialAyuda), findsNothing);
|
||||
expect(find.text('home stand-in'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Saltar pops immediately from a middle page, not only the first',
|
||||
(tester) async {
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: Center(child: Text('home stand-in'))),
|
||||
),
|
||||
);
|
||||
|
||||
unawaited(
|
||||
navigatorKey.currentState!.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const PantallaTutorialAyuda(primerArranque: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
pageView.controller!.jumpToPage(3);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text(l10n.tutorialSkipAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaTutorialAyuda), findsNothing);
|
||||
expect(find.text('home stand-in'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('Siguiente advances from the first page to the second', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_app(primerArranque: true));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(l10n.tutorialPage1Headline), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(l10n.tutorialNextAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(l10n.tutorialPage2Headline), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Saltar is not offered on the last page', (tester) async {
|
||||
await tester.pumpWidget(_app(primerArranque: true));
|
||||
await tester.pump();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
pageView.controller!.jumpToPage(8);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(l10n.tutorialSkipAction), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'last page CTA reads "Empezar a escuchar" when reached via first launch',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_app(primerArranque: true));
|
||||
await tester.pump();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
pageView.controller!.jumpToPage(8);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(l10n.welcomeCtaLabel), findsOneWidget);
|
||||
expect(find.text(l10n.closeAction), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'last page CTA reads "Cerrar" when reached manually from Ajustes',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_app(primerArranque: false));
|
||||
await tester.pump();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
pageView.controller!.jumpToPage(8);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(l10n.closeAction), findsOneWidget);
|
||||
expect(find.text(l10n.welcomeCtaLabel), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('tapping the last page CTA pops the route', (tester) async {
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: Center(child: Text('home stand-in'))),
|
||||
),
|
||||
);
|
||||
|
||||
unawaited(
|
||||
navigatorKey.currentState!.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const PantallaTutorialAyuda(primerArranque: false),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
pageView.controller!.jumpToPage(8);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text(l10n.closeAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaTutorialAyuda), findsNothing);
|
||||
expect(find.text('home stand-in'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the last page shows the "watch it again" reminder banner', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_app(primerArranque: true));
|
||||
await tester.pump();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
pageView.controller!.jumpToPage(8);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(l10n.tutorialPage9BannerBody), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/servicio_tutorial_ayuda.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// `ServicioTutorialAyuda` decides whether the 9-screen help/tutorial
|
||||
/// carousel (`PantallaTutorialAyuda`) is still due on this launch. Same
|
||||
/// injectable-prefs, versioned-key convention as `ServicioBienvenida`: a
|
||||
/// plain one-time boolean flag, since this tutorial shows once (to both
|
||||
/// fresh installs and existing installs upgrading to this version) and is
|
||||
/// otherwise only reachable manually from Ajustes.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('ServicioTutorialAyuda', () {
|
||||
test(
|
||||
'debeMostrarTutorial is true before it has ever been marked seen',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final servicio = ServicioTutorialAyuda();
|
||||
|
||||
expect(await servicio.debeMostrarTutorial(), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('debeMostrarTutorial is false after marcarTutorialVisto', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final servicio = ServicioTutorialAyuda();
|
||||
|
||||
await servicio.marcarTutorialVisto();
|
||||
|
||||
expect(await servicio.debeMostrarTutorial(), isFalse);
|
||||
});
|
||||
|
||||
test('respects a seen flag already persisted by a prior launch', () async {
|
||||
SharedPreferences.setMockInitialValues({'pluri_tutorial_visto_v1': true});
|
||||
final servicio = ServicioTutorialAyuda();
|
||||
|
||||
expect(await servicio.debeMostrarTutorial(), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'is still due on an existing install that has other unrelated flags '
|
||||
'set but never this one (e.g. the welcome screen already seen)',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'pluri_bienvenida_vista_v1': true,
|
||||
});
|
||||
final servicio = ServicioTutorialAyuda();
|
||||
|
||||
expect(await servicio.debeMostrarTutorial(), isTrue);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -12,12 +12,23 @@ 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() {
|
||||
Widget host(Widget child) {
|
||||
// [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}) {
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -42,7 +53,13 @@ void main() {
|
||||
host(PluriRootHeader(title: 'Settings', onSleepTimer: () {})),
|
||||
);
|
||||
|
||||
final padding = tester.widget<Padding>(find.byType(Padding).first);
|
||||
// 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<Padding>(
|
||||
find.byKey(const ValueKey('pluri-root-header-content')),
|
||||
);
|
||||
final insets = padding.padding as EdgeInsets;
|
||||
expect(insets.left, PluriLayout.titleHorizontal);
|
||||
expect(insets.right, PluriLayout.rowHorizontal);
|
||||
@@ -97,4 +114,92 @@ 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<double> 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user