feat(tutorial): wire tutorial carousel into the first-launch flow
Insert PantallaTutorialAyuda.mostrarSiProcede between the welcome screen and the recurring what's-new dialog in _mostrarFlujoPrimerLanzamiento, so the carousel shows once on every install -- fresh AND existing installs upgrading to this version -- via its own independent one-time flag, without racing either surface.
This commit is contained in:
@@ -16,6 +16,7 @@ import 'pantallas/pantalla_alarmas.dart';
|
|||||||
import 'pantallas/pantalla_alarma_sonando.dart';
|
import 'pantallas/pantalla_alarma_sonando.dart';
|
||||||
import 'pantallas/pantalla_bienvenida.dart';
|
import 'pantallas/pantalla_bienvenida.dart';
|
||||||
import 'pantallas/pantalla_inicio.dart';
|
import 'pantallas/pantalla_inicio.dart';
|
||||||
|
import 'pantallas/pantalla_tutorial_ayuda.dart';
|
||||||
import 'pantallas/pantalla_buscar.dart';
|
import 'pantallas/pantalla_buscar.dart';
|
||||||
import 'pantallas/pantalla_favoritos.dart';
|
import 'pantallas/pantalla_favoritos.dart';
|
||||||
import 'pantallas/pantalla_ajustes.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
|
// pre-existing PluriOnboardingDialog is an unrelated "what's new"/help
|
||||||
// modal that keeps its own independent per-version due-or-not logic,
|
// modal that keeps its own independent per-version due-or-not logic,
|
||||||
// completely unchanged by this sequencing.
|
// 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<void> _mostrarFlujoPrimerLanzamiento() async {
|
Future<void> _mostrarFlujoPrimerLanzamiento() async {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
await PantallaBienvenida.mostrarSiProcede(context);
|
await PantallaBienvenida.mostrarSiProcede(context);
|
||||||
}
|
}
|
||||||
|
if (mounted) {
|
||||||
|
await PantallaTutorialAyuda.mostrarSiProcede(context);
|
||||||
|
}
|
||||||
await _mostrarOnboardingInicial();
|
await _mostrarOnboardingInicial();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,59 +29,41 @@ class PluriRootHeader extends StatelessWidget {
|
|||||||
/// needs nothing extra here).
|
/// needs nothing extra here).
|
||||||
final List<Widget> actions;
|
final List<Widget> 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;
|
static const double height = 56;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final type = context.pluriType;
|
final type = context.pluriType;
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final topInset = MediaQuery.paddingOf(context).top;
|
return SizedBox(
|
||||||
return Padding(
|
height: height,
|
||||||
padding: EdgeInsets.only(top: topInset),
|
child: Padding(
|
||||||
child: SizedBox(
|
// S5: the prototype's own header padding is title-tier on the
|
||||||
height: height,
|
// left, row-tier on the right (t4 e.g. Alarmas
|
||||||
child: Padding(
|
// `padding:0 12px 0 20px`).
|
||||||
key: const ValueKey('pluri-root-header-content'),
|
padding: const EdgeInsets.fromLTRB(
|
||||||
// S5: the prototype's own header padding is title-tier on the
|
PluriLayout.titleHorizontal,
|
||||||
// left, row-tier on the right (t4 e.g. Alarmas
|
0,
|
||||||
// `padding:0 12px 0 20px`).
|
PluriLayout.rowHorizontal,
|
||||||
padding: const EdgeInsets.fromLTRB(
|
0,
|
||||||
PluriLayout.titleHorizontal,
|
),
|
||||||
0,
|
child: Row(
|
||||||
PluriLayout.rowHorizontal,
|
children: [
|
||||||
0,
|
Expanded(
|
||||||
),
|
child: Text(
|
||||||
child: Row(
|
title,
|
||||||
children: [
|
maxLines: 1,
|
||||||
Expanded(
|
overflow: TextOverflow.ellipsis,
|
||||||
child: Text(
|
style: type.sectionTitle,
|
||||||
title,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: type.sectionTitle,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
...actions,
|
),
|
||||||
IconButton(
|
...actions,
|
||||||
icon: const Icon(Icons.bedtime_outlined),
|
IconButton(
|
||||||
tooltip: l10n.sleepTimer,
|
icon: const Icon(Icons.bedtime_outlined),
|
||||||
onPressed: onSleepTimer,
|
tooltip: l10n.sleepTimer,
|
||||||
),
|
onPressed: onSleepTimer,
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,4 +20,53 @@ void main() {
|
|||||||
'PluriRootHeader inside its content instead',
|
'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',
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// sleep-timer action that used to live on `app.dart`'s single global
|
||||||
/// `AppBar` stays reachable from every tab.
|
/// `AppBar` stays reachable from every tab.
|
||||||
void main() {
|
void main() {
|
||||||
// [topInset] simulates `MediaQuery.paddingOf(context).top` (status bar /
|
Widget host(Widget child) {
|
||||||
// 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(
|
return MaterialApp(
|
||||||
theme: PluriWaveTheme.dark(),
|
theme: PluriWaveTheme.dark(),
|
||||||
locale: const Locale('en'),
|
locale: const Locale('en'),
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
builder:
|
|
||||||
(context, app) => MediaQuery(
|
|
||||||
data: MediaQuery.of(
|
|
||||||
context,
|
|
||||||
).copyWith(padding: EdgeInsets.only(top: topInset)),
|
|
||||||
child: app!,
|
|
||||||
),
|
|
||||||
home: Scaffold(body: child),
|
home: Scaffold(body: child),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -53,13 +42,7 @@ void main() {
|
|||||||
host(PluriRootHeader(title: 'Settings', onSleepTimer: () {})),
|
host(PluriRootHeader(title: 'Settings', onSleepTimer: () {})),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keyed lookup (not `find.byType(Padding).first`): the top-inset fix
|
final padding = tester.widget<Padding>(find.byType(Padding).first);
|
||||||
// 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;
|
final insets = padding.padding as EdgeInsets;
|
||||||
expect(insets.left, PluriLayout.titleHorizontal);
|
expect(insets.left, PluriLayout.titleHorizontal);
|
||||||
expect(insets.right, PluriLayout.rowHorizontal);
|
expect(insets.right, PluriLayout.rowHorizontal);
|
||||||
@@ -114,92 +97,4 @@ void main() {
|
|||||||
reason: 'actions render before (to the left of) the bedtime button',
|
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