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.
364 lines
12 KiB
Dart
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),
|
|
),
|
|
);
|
|
}
|
|
}
|