Files
pluriwave/lib/pantallas/pantalla_tutorial_ayuda.dart
FreeTLab 015a20a823 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.
2026-08-01 11:34:53 +02:00

364 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import '../l10n/gen/app_localizations.dart';
import '../servicios/servicio_tutorial_ayuda.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
/// 9-screen help/tutorial carousel (mockup screens 5b..5h, reading order
/// 1..9). Reachable two ways:
/// - [mostrarSiProcede]: the genuine first-launch sequence in `app.dart`,
/// run once ever (both fresh installs AND existing installs upgrading to
/// this version), via [ServicioTutorialAyuda]'s plain one-time flag.
/// - Manually from Ajustes > Info > "Ayuda y tutorial", constructed directly
/// with `primerArranque: false`.
///
/// [primerArranque] only changes the LAST page's CTA label -- "Empezar a
/// escuchar" on a first-launch entry, "Cerrar" otherwise. Every other
/// behaviour (Saltar pops immediately, Siguiente advances) is identical
/// regardless of entry point; both cases simply pop the route when
/// finished, letting whatever screen is already mounted underneath show.
class PantallaTutorialAyuda extends StatefulWidget {
const PantallaTutorialAyuda({super.key, required this.primerArranque});
final bool primerArranque;
static final ServicioTutorialAyuda _servicio = ServicioTutorialAyuda();
static const int cantidadPaginas = 9;
/// Shows this carousel once, on the genuine first-launch sequence, then
/// never again. Mirrors `PantallaBienvenida.mostrarSiProcede`'s shape
/// (check-then-show-then-mark-seen).
static Future<void> mostrarSiProcede(BuildContext context) async {
if (!await _servicio.debeMostrarTutorial()) return;
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const PantallaTutorialAyuda(primerArranque: true),
),
);
await _servicio.marcarTutorialVisto();
}
@override
State<PantallaTutorialAyuda> createState() => _PantallaTutorialAyudaState();
}
class _PantallaTutorialAyudaState extends State<PantallaTutorialAyuda> {
final _controller = PageController();
int _pagina = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
bool get _esUltimaPagina =>
_pagina == PantallaTutorialAyuda.cantidadPaginas - 1;
void _saltar() => Navigator.of(context).pop();
void _siguiente() {
if (_esUltimaPagina) {
Navigator.of(context).pop();
return;
}
_controller.nextPage(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final paginas = _construirPaginas(l10n);
return Scaffold(
body: SafeArea(
child: Column(
children: [
SizedBox(
height: 48,
child: Align(
alignment: Alignment.centerRight,
child:
_esUltimaPagina
? null
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: TextButton(
onPressed: _saltar,
child: Text(l10n.tutorialSkipAction),
),
),
),
),
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: PantallaTutorialAyuda.cantidadPaginas,
onPageChanged: (indice) => setState(() => _pagina = indice),
itemBuilder:
(context, indice) =>
TarjetaPaginaTutorial(datos: paginas[indice]),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (
var i = 0;
i < PantallaTutorialAyuda.cantidadPaginas;
i++
)
PuntoIndicadorTutorial(activo: i == _pagina),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: SizedBox(
height: 58,
width: double.infinity,
child: FilledButton(
onPressed: _siguiente,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
child: Text(
_esUltimaPagina
? (widget.primerArranque
? l10n.welcomeCtaLabel
: l10n.closeAction)
: l10n.tutorialNextAction,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
),
),
),
],
),
),
);
}
List<DatosPaginaTutorial> _construirPaginas(AppLocalizations l10n) {
final t = PluriWaveTokens.dark;
return [
DatosPaginaTutorial(
icono: Icons.favorite_rounded,
color: t.electricMagenta,
titulo: l10n.tutorialPage1Headline,
cuerpo: l10n.tutorialPage1Body,
),
DatosPaginaTutorial(
icono: Icons.equalizer_rounded,
color: t.liveGreen,
titulo: l10n.tutorialPage2Headline,
cuerpo: l10n.tutorialPage2Body,
),
DatosPaginaTutorial(
icono: Icons.mic_rounded,
color: t.warmCoral,
titulo: l10n.tutorialPage3Headline,
cuerpo: l10n.tutorialPage3Body,
),
DatosPaginaTutorial(
icono: Icons.alarm_rounded,
color: t.offlineAccent,
titulo: l10n.tutorialPage4Headline,
cuerpo: l10n.tutorialPage4Body,
),
DatosPaginaTutorial(
icono: Icons.directions_car_rounded,
color: PluriWaveTokens.skyBlue,
titulo: l10n.tutorialPage5Headline,
cuerpo: l10n.tutorialPage5Body,
),
DatosPaginaTutorial(
icono: Icons.wifi_tethering_rounded,
color: t.liveGreen,
titulo: l10n.tutorialPage6Headline,
cuerpo: l10n.tutorialPage6Body,
),
DatosPaginaTutorial(
icono: Icons.snooze_rounded,
color: t.warmCoral,
titulo: l10n.tutorialPage7Headline,
cuerpo: l10n.tutorialPage7Body,
),
DatosPaginaTutorial(
icono: Icons.add_link_rounded,
color: PluriWaveTokens.skyBlue,
titulo: l10n.tutorialPage8Headline,
cuerpo: l10n.tutorialPage8Body,
),
DatosPaginaTutorial(
icono: Icons.check_circle_rounded,
color: t.electricMagenta,
titulo: l10n.tutorialPage9Headline,
// Last page only: the "watch it again" reminder banner (design
// ADR text, mockup screen 5h). No progress-dot advancement beyond
// this page -- it is the final one.
bannerCuerpo: l10n.tutorialPage9BannerBody,
),
];
}
}
/// Content for a single carousel page: icon badge, headline, body, and --
/// only on the last page -- the "watch it again" reminder banner.
class DatosPaginaTutorial {
const DatosPaginaTutorial({
required this.icono,
required this.color,
required this.titulo,
this.cuerpo,
this.bannerCuerpo,
});
final IconData icono;
final Color color;
final String titulo;
/// Body copy below the headline. `null` on the last page (mockup screen
/// 5h), which shows only the headline plus [bannerCuerpo] -- no separate
/// body paragraph.
final String? cuerpo;
final String? bannerCuerpo;
}
/// One carousel page: a 150x150 rounded-square icon badge, headline, body,
/// and -- when [DatosPaginaTutorial.bannerCuerpo] is set -- the reminder
/// banner. Public (not `_TarjetaPagina`) so tests can target pages by type,
/// same reason `FilaCaracteristicaBienvenida` is public.
class TarjetaPaginaTutorial extends StatelessWidget {
const TarjetaPaginaTutorial({super.key, required this.datos});
final DatosPaginaTutorial datos;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 150,
height: 150,
decoration: BoxDecoration(
color: datos.color.withValues(alpha: 0.13),
borderRadius: BorderRadius.circular(32),
),
child: Icon(datos.icono, size: 72, color: datos.color),
),
const SizedBox(height: 32),
Text(
datos.titulo,
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.4,
height: 1.15,
),
),
if (datos.cuerpo case final cuerpo?) ...[
const SizedBox(height: 12),
Text(
cuerpo,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 14,
height: 1.5,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
if (datos.bannerCuerpo case final bannerCuerpo?) ...[
const SizedBox(height: 24),
_BannerRecordatorio(texto: bannerCuerpo),
],
],
),
);
}
}
class _BannerRecordatorio extends StatelessWidget {
const _BannerRecordatorio({required this.texto});
final String texto;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline_rounded,
size: 20,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
const SizedBox(width: 10),
Expanded(
child: Text(
texto,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12.5,
height: 1.4,
color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
),
),
),
],
),
);
}
}
/// One dot in the 9-dot progress indicator: wider and teal when [activo],
/// small and translucent otherwise. Public so tests can assert "exactly 9
/// dots" via `find.byType`.
class PuntoIndicadorTutorial extends StatelessWidget {
const PuntoIndicadorTutorial({super.key, required this.activo});
final bool activo;
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: activo ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color:
activo ? t.electricMagenta : Colors.white.withValues(alpha: 0.24),
borderRadius: BorderRadius.circular(4),
),
);
}
}