feat(app): add onboarding and harden alarms
This commit is contained in:
+21
-2
@@ -16,6 +16,7 @@ import 'tema/pluriwave_theme.dart';
|
||||
import 'widgets/pluri_bottom_navigation.dart';
|
||||
import 'widgets/pluri_icon.dart';
|
||||
import 'widgets/pluri_layout.dart';
|
||||
import 'widgets/pluri_onboarding_dialog.dart';
|
||||
import 'widgets/pluri_wave_scaffold.dart';
|
||||
import 'package:pluriwave/widgets/mini_reproductor.dart';
|
||||
import 'servicios/servicio_alarmas_android.dart';
|
||||
@@ -64,6 +65,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||
EstadoRadio? _estadoSuscrito;
|
||||
bool _alarmaInicialProcesada = false;
|
||||
bool _alarmaSonandoActiva = false;
|
||||
bool _onboardingInicialSolicitado = false;
|
||||
String? _alarmaSonandoId;
|
||||
|
||||
static const _paginas = [
|
||||
@@ -120,6 +122,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||
_alarmaInicialProcesada = true;
|
||||
unawaited(_procesarAlarmaInicial(alarmas));
|
||||
}
|
||||
if (!_onboardingInicialSolicitado) {
|
||||
_onboardingInicialSolicitado = true;
|
||||
unawaited(_mostrarOnboardingInicial());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -196,9 +202,17 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _mostrarOnboardingInicial() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 900));
|
||||
if (!mounted || _alarmaSonandoActiva) return;
|
||||
await PluriOnboardingDialog.mostrarSiProcede(context);
|
||||
}
|
||||
|
||||
Future<void> _abrirAlarmaSonando(EventoAlarmaAndroid evento) async {
|
||||
final estado = context.read<EstadoAlarmas>();
|
||||
await estado.refrescarProgramacion();
|
||||
if (estado.alarmas.isEmpty) {
|
||||
await estado.cargarPersistidasSinRecalcular();
|
||||
}
|
||||
AlarmaMusical? alarma;
|
||||
for (final item in estado.alarmas) {
|
||||
if (item.id == evento.alarmaId) {
|
||||
@@ -206,7 +220,12 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (alarma == null || !mounted) return;
|
||||
if (alarma == null || !mounted) {
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] evento sin alarma persistida id=${evento.alarmaId} accion=${evento.accion}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (evento.accion.endsWith('.SKIP_NEXT')) {
|
||||
await estado.saltarProxima(alarma.id);
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -27,7 +27,8 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
DiagnosticoAlarmasAndroid? _diagnostico;
|
||||
Timer? _refresco;
|
||||
Timer? _vigilancia;
|
||||
final _alarmasVencidasController = StreamController<AlarmaMusical>.broadcast();
|
||||
final _alarmasVencidasController =
|
||||
StreamController<AlarmaMusical>.broadcast();
|
||||
final Set<String> _ejecucionesEmitidas = {};
|
||||
static const _margenDisparoLocal = Duration(seconds: 45);
|
||||
bool _cargando = false;
|
||||
@@ -57,7 +58,9 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
try {
|
||||
final config = await servicio.recalcularTodas();
|
||||
_aplicar(config);
|
||||
debugPrint('[PluriWave][alarmas] cargadas=${_alarmas.length} vacaciones=${_vacaciones.length} excepciones=${_excepciones.length}');
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] cargadas=${_alarmas.length} vacaciones=${_vacaciones.length} excepciones=${_excepciones.length}',
|
||||
);
|
||||
await _sincronizarTodas();
|
||||
await cargarDiagnostico();
|
||||
_activarRefresco();
|
||||
@@ -71,16 +74,19 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> guardarAlarma(AlarmaMusical alarma) async {
|
||||
debugPrint('[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}');
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}',
|
||||
);
|
||||
final config = await servicio.guardarAlarma(alarma);
|
||||
_aplicar(config);
|
||||
try {
|
||||
final guardada = _alarmas.firstWhere((a) => a.id == alarma.id);
|
||||
debugPrint('[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}');
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}',
|
||||
);
|
||||
await android.programar(guardada);
|
||||
} catch (e) {
|
||||
_error =
|
||||
'Alarma guardada, pero Android no pudo programarla todavía: $e';
|
||||
_error = 'Alarma guardada, pero Android no pudo programarla todavía: $e';
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -96,6 +102,12 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> cargarPersistidasSinRecalcular() async {
|
||||
final config = await servicio.cargar();
|
||||
_aplicar(config);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void marcarEjecucionGestionada(AlarmaMusical alarma) {
|
||||
final proxima = alarma.proximaEjecucion;
|
||||
if (proxima == null) return;
|
||||
@@ -110,6 +122,7 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
debugPrint('[PluriWave][alarmas] eliminar id=$id');
|
||||
final config = await servicio.eliminarAlarma(id);
|
||||
_aplicar(config);
|
||||
await android.detenerSonidoNativo(id);
|
||||
await android.cancelar(id);
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -136,7 +149,9 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> guardarVacaciones(List<RangoVacaciones> vacaciones) async {
|
||||
debugPrint('[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}');
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}',
|
||||
);
|
||||
final config = await servicio.guardarVacaciones(vacaciones);
|
||||
_aplicar(config);
|
||||
await _sincronizarTodas();
|
||||
@@ -145,7 +160,9 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
|
||||
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
|
||||
final proxima = DateTime.now().add(Duration(minutes: minutos));
|
||||
debugPrint('[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos proxima=${proxima.toIso8601String()}');
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos proxima=${proxima.toIso8601String()}',
|
||||
);
|
||||
await android.ocultarNotificacionAlarma(alarma.id);
|
||||
await android.programar(alarma.copyWith(proximaEjecucion: proxima));
|
||||
}
|
||||
@@ -184,7 +201,9 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _sincronizarTodas() async {
|
||||
debugPrint('[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}');
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}',
|
||||
);
|
||||
for (final alarma in _alarmas) {
|
||||
await android.programar(alarma);
|
||||
}
|
||||
@@ -224,7 +243,9 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
continue;
|
||||
}
|
||||
if (_ejecucionesEmitidas.add(key)) {
|
||||
debugPrint('[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}');
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}',
|
||||
);
|
||||
_alarmasVencidasController.add(alarma);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,8 @@ class EstadoRadio extends ChangeNotifier {
|
||||
List<Emisora> get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda);
|
||||
List<Emisora> get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas);
|
||||
List<Emisora> get listaFavoritos => _ordenarEmisoras(_listaFavoritos);
|
||||
List<GrupoFavoritos> get gruposFavoritos => List.unmodifiable(_gruposFavoritos);
|
||||
List<GrupoFavoritos> get gruposFavoritos =>
|
||||
List.unmodifiable(_gruposFavoritos);
|
||||
List<Emisora> get emisorasCustom => _ordenarEmisoras(_emisorasCustom);
|
||||
bool get cargandoPopulares => _cargandoPopulares;
|
||||
bool get cargandoBusqueda => _cargandoBusqueda;
|
||||
@@ -633,7 +634,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
await Directory(ruta).create(recursive: true);
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
final abierto = await _fileActionsChannel.invokeMethod<bool>(
|
||||
'openDirectory',
|
||||
'viewDirectory',
|
||||
{'path': ruta},
|
||||
);
|
||||
return abierto ?? false;
|
||||
@@ -650,13 +651,10 @@ class EstadoRadio extends ChangeNotifier {
|
||||
}
|
||||
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}');
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
final abierto = await _fileActionsChannel.invokeMethod<bool>(
|
||||
'openFile',
|
||||
{
|
||||
'path': archivo.path,
|
||||
'mimeType': 'audio/*',
|
||||
},
|
||||
);
|
||||
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
|
||||
'path': archivo.path,
|
||||
'mimeType': 'audio/*',
|
||||
});
|
||||
return abierto ?? false;
|
||||
}
|
||||
return launchUrl(
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../widgets/ecualizador_widget.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_onboarding_dialog.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
|
||||
class PantallaAjustes extends StatelessWidget {
|
||||
@@ -225,7 +226,6 @@ class _SeccionGrabaciones extends StatelessWidget {
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () => _seleccionarRuta(context),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
@@ -353,7 +353,8 @@ class _SeccionTimerSueno extends StatelessWidget {
|
||||
icon: const Icon(Icons.restore_rounded),
|
||||
label: Text(l10n.timerSectionRestoreRecommended),
|
||||
onPressed:
|
||||
() => context.read<EstadoRadio>().restaurarTimerSuenoPresets(),
|
||||
() =>
|
||||
context.read<EstadoRadio>().restaurarTimerSuenoPresets(),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -387,8 +388,7 @@ class _SeccionIdioma extends StatelessWidget {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final estadoIdioma = context.watch<EstadoIdioma>();
|
||||
final locale = estadoIdioma.localeSeleccionado;
|
||||
final valorActual =
|
||||
locale == null ? _codigoSistema : _codigoLocale(locale);
|
||||
final valorActual = locale == null ? _codigoSistema : _codigoLocale(locale);
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
@@ -479,7 +479,8 @@ class _FormularioDuracionTimer extends StatefulWidget {
|
||||
const _FormularioDuracionTimer();
|
||||
|
||||
@override
|
||||
State<_FormularioDuracionTimer> createState() => _FormularioDuracionTimerState();
|
||||
State<_FormularioDuracionTimer> createState() =>
|
||||
_FormularioDuracionTimerState();
|
||||
}
|
||||
|
||||
class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
|
||||
@@ -506,9 +507,9 @@ class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
|
||||
seconds: _leer(_segundosCtrl),
|
||||
);
|
||||
if (duracion <= Duration.zero) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.durationGreaterThanZero)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.durationGreaterThanZero)));
|
||||
return;
|
||||
}
|
||||
Navigator.pop(context, duracion);
|
||||
@@ -593,7 +594,9 @@ class _SeccionEcualizador extends StatelessWidget {
|
||||
const Spacer(),
|
||||
Chip(
|
||||
label: Text(
|
||||
estado.ecualizadorActivo ? l10n.equalizerActive : l10n.equalizerDisabled,
|
||||
estado.ecualizadorActivo
|
||||
? l10n.equalizerActive
|
||||
: l10n.equalizerDisabled,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
@@ -701,7 +704,10 @@ class _SeccionOrdenListas extends StatelessWidget {
|
||||
class _SeccionGruposFavoritos extends StatelessWidget {
|
||||
const _SeccionGruposFavoritos();
|
||||
|
||||
Future<void> _editarGrupo(BuildContext context, [GrupoFavoritos? grupo]) async {
|
||||
Future<void> _editarGrupo(
|
||||
BuildContext context, [
|
||||
GrupoFavoritos? grupo,
|
||||
]) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final controller = TextEditingController(text: grupo?.nombre ?? '');
|
||||
final nombre = await showModalBottomSheet<String>(
|
||||
@@ -717,7 +723,9 @@ class _SeccionGruposFavoritos extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
grupo == null ? l10n.favoriteGroupsAdd : l10n.favoriteGroupsEdit,
|
||||
grupo == null
|
||||
? l10n.favoriteGroupsAdd
|
||||
: l10n.favoriteGroupsEdit,
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -756,17 +764,26 @@ class _SeccionGruposFavoritos extends StatelessWidget {
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(grupo == null ? l10n.favoriteGroupsCreated : l10n.favoriteGroupsUpdated)),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
grupo == null
|
||||
? l10n.favoriteGroupsCreated
|
||||
: l10n.favoriteGroupsUpdated,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _eliminarGrupo(BuildContext context, GrupoFavoritos grupo) async {
|
||||
Future<void> _eliminarGrupo(
|
||||
BuildContext context,
|
||||
GrupoFavoritos grupo,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
await context.read<EstadoRadio>().eliminarGrupoFavoritos(grupo.id);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.favoriteGroupsDeleted)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.favoriteGroupsDeleted)));
|
||||
}
|
||||
|
||||
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
|
||||
@@ -808,24 +825,28 @@ class _SeccionGruposFavoritos extends StatelessWidget {
|
||||
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
|
||||
),
|
||||
title: Text(_nombreVisible(l10n, grupo)),
|
||||
subtitle: grupo.esSinAsignar ? Text(l10n.favoriteGroupsProtectedHint) : null,
|
||||
trailing: grupo.esSinAsignar
|
||||
? null
|
||||
: Wrap(
|
||||
spacing: 4,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.favoriteGroupsEdit,
|
||||
icon: const Icon(Icons.edit_rounded),
|
||||
onPressed: () => _editarGrupo(context, grupo),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.favoriteGroupsDelete,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
onPressed: () => _eliminarGrupo(context, grupo),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle:
|
||||
grupo.esSinAsignar
|
||||
? Text(l10n.favoriteGroupsProtectedHint)
|
||||
: null,
|
||||
trailing:
|
||||
grupo.esSinAsignar
|
||||
? null
|
||||
: Wrap(
|
||||
spacing: 4,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.favoriteGroupsEdit,
|
||||
icon: const Icon(Icons.edit_rounded),
|
||||
onPressed: () => _editarGrupo(context, grupo),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.favoriteGroupsDelete,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
onPressed: () => _eliminarGrupo(context, grupo),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -911,7 +932,10 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: Text(l10n.preferredStationPlay),
|
||||
onPressed:
|
||||
() => context.read<EstadoRadio>().reproducirEmisoraPreferida(),
|
||||
() =>
|
||||
context
|
||||
.read<EstadoRadio>()
|
||||
.reproducirEmisoraPreferida(),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -925,7 +949,9 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
|
||||
estado.listaFavoritos.isNotEmpty
|
||||
? estado.listaFavoritos
|
||||
: estado.emisorasDisponiblesPreferencia;
|
||||
final mapa = <String, Emisora>{for (final emisora in base) emisora.uuid: emisora};
|
||||
final mapa = <String, Emisora>{
|
||||
for (final emisora in base) emisora.uuid: emisora,
|
||||
};
|
||||
if (preferida != null) {
|
||||
mapa[preferida.uuid] = preferida;
|
||||
}
|
||||
@@ -1055,7 +1081,12 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
|
||||
Widget build(BuildContext context) {
|
||||
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(PluriLayout.horizontal, PluriLayout.horizontal, PluriLayout.horizontal, PluriLayout.horizontal + bottom),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.horizontal + bottom,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
@@ -1089,7 +1120,9 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return AppLocalizations.of(context).requiredField;
|
||||
if (v == null || v.trim().isEmpty) {
|
||||
return AppLocalizations.of(context).requiredField;
|
||||
}
|
||||
final uri = Uri.tryParse(v.trim());
|
||||
if (uri == null || !uri.hasScheme) return 'URL no válida';
|
||||
return null;
|
||||
@@ -1143,9 +1176,13 @@ class _SeccionBackup extends StatelessWidget {
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).backupExportError(e.toString()))));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context).backupExportError(e.toString()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1197,9 +1234,13 @@ class _SeccionBackup extends StatelessWidget {
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).backupImportError(e.toString()))));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context).backupImportError(e.toString()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1264,7 +1305,9 @@ class _SeccionInfo extends StatelessWidget {
|
||||
variant: PluriIconVariant.filled,
|
||||
),
|
||||
title: const Text('PluriWave'),
|
||||
subtitle: Text(AppLocalizations.of(ctx).appVersionSubtitle(version)),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(ctx).appVersionSubtitle(version),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -1274,18 +1317,30 @@ class _SeccionInfo extends StatelessWidget {
|
||||
(ctx, snap) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.favorite_outline),
|
||||
title: Text(AppLocalizations.of(ctx).savedFavoritesTitle),
|
||||
title: Text(
|
||||
AppLocalizations.of(ctx).savedFavoritesTitle,
|
||||
),
|
||||
trailing: Text(
|
||||
snap.data?.toString() ?? '—',
|
||||
style: Theme.of(ctx).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.help_outline_rounded),
|
||||
title: Text(_helpTitle(ctx)),
|
||||
subtitle: Text(_helpSubtitle(ctx)),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => PluriOnboardingDialog.mostrar(ctx),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.verified_outlined),
|
||||
title: Text(AppLocalizations.of(ctx).stationFilterTitle),
|
||||
subtitle: Text(AppLocalizations.of(ctx).stationFilterSubtitle),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(ctx).stationFilterSubtitle,
|
||||
),
|
||||
trailing: const Icon(Icons.check_circle, color: Colors.green),
|
||||
),
|
||||
const ListTile(
|
||||
@@ -1302,6 +1357,28 @@ class _SeccionInfo extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
String _helpTitle(BuildContext context) => switch (Localizations.localeOf(
|
||||
context,
|
||||
).languageCode) {
|
||||
'es' => 'Ayuda y tutorial',
|
||||
'fr' => 'Aide et tutoriel',
|
||||
'de' => 'Hilfe und Tutorial',
|
||||
'it' => 'Aiuto e tutorial',
|
||||
'pt' => 'Ajuda e tutorial',
|
||||
_ => 'Help and tutorial',
|
||||
};
|
||||
|
||||
String _helpSubtitle(BuildContext context) => switch (Localizations.localeOf(
|
||||
context,
|
||||
).languageCode) {
|
||||
'es' => 'Repasá funciones, consejos y novedades de PluriWave.',
|
||||
'fr' => 'Revoyez les fonctions, conseils et nouveautés de PluriWave.',
|
||||
'de' => 'Funktionen, Tipps und Neuigkeiten von PluriWave ansehen.',
|
||||
'it' => 'Rivedi funzioni, consigli e novità di PluriWave.',
|
||||
'pt' => 'Revê funções, dicas e novidades do PluriWave.',
|
||||
_ => 'Review PluriWave features, tips and what’s new.',
|
||||
};
|
||||
|
||||
String _formatearDuracionTimer(Duration duracion) {
|
||||
final horas = duracion.inHours;
|
||||
final minutos = duracion.inMinutes.remainder(60);
|
||||
|
||||
+228
-126
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../estado/estado_alarmas.dart';
|
||||
@@ -58,7 +58,10 @@ class PantallaAlarmas extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _abrirEditor(BuildContext context, {AlarmaMusical? alarma}) async {
|
||||
Future<void> _abrirEditor(
|
||||
BuildContext context, {
|
||||
AlarmaMusical? alarma,
|
||||
}) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
@@ -77,6 +80,10 @@ class _PanelProximaAlarma extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final proxima = estado.proximaAlarma;
|
||||
final activasSinProxima =
|
||||
estado.alarmas
|
||||
.where((a) => a.activa && a.proximaEjecucion == null)
|
||||
.length;
|
||||
return PluriGlassSurface(
|
||||
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28),
|
||||
child: Row(
|
||||
@@ -88,7 +95,11 @@ class _PanelProximaAlarma extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
proxima == null ? 'Sin alarmas activas' : 'Próxima alarma',
|
||||
proxima == null
|
||||
? activasSinProxima > 0
|
||||
? 'Alarmas activas sin próxima ejecución'
|
||||
: 'Sin alarmas activas'
|
||||
: 'Próxima alarma',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
@@ -96,7 +107,9 @@ class _PanelProximaAlarma extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
proxima == null
|
||||
? 'Creá una alarma y PluriWave calculará la siguiente ejecución automáticamente.'
|
||||
? activasSinProxima > 0
|
||||
? 'Hay $activasSinProxima alarma(s) activas, pero ahora mismo no tienen una fecha futura válida. Revisá fecha, días y vacaciones.'
|
||||
: 'Creá una alarma y PluriWave calculará la siguiente ejecución automáticamente.'
|
||||
: '${proxima.nombre} · ${_fechaHora(proxima.proximaEjecucion!)}',
|
||||
),
|
||||
],
|
||||
@@ -125,7 +138,10 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 64),
|
||||
const _AssetIcon(
|
||||
'assets/icons/alarmas/alarm_music.png',
|
||||
size: 64,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -133,7 +149,9 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
_hora(alarma),
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
@@ -153,13 +171,20 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_InfoChip(icon: Icons.repeat_rounded, label: _programacion(alarma)),
|
||||
_InfoChip(icon: Icons.snooze_rounded, label: '${alarma.snoozeMinutos} min'),
|
||||
_InfoChip(
|
||||
icon: Icons.repeat_rounded,
|
||||
label: _programacion(alarma),
|
||||
),
|
||||
_InfoChip(
|
||||
icon: Icons.snooze_rounded,
|
||||
label: '${alarma.snoozeMinutos} min',
|
||||
),
|
||||
_InfoChip(
|
||||
icon: Icons.beach_access_rounded,
|
||||
label: alarma.sonarEnVacaciones
|
||||
? 'Suena en vacaciones'
|
||||
: 'Pausa en vacaciones',
|
||||
label:
|
||||
alarma.sonarEnVacaciones
|
||||
? 'Suena en vacaciones'
|
||||
: 'Pausa en vacaciones',
|
||||
),
|
||||
_InfoChip(
|
||||
icon: Icons.volume_up_rounded,
|
||||
@@ -171,7 +196,8 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
if (alarma.proximaEjecucion != null)
|
||||
_NoticeLine(
|
||||
icon: Icons.event_available_rounded,
|
||||
text: 'Siguiente ejecución: ${_fechaHora(alarma.proximaEjecucion!)}',
|
||||
text:
|
||||
'Siguiente ejecución: ${_fechaHora(alarma.proximaEjecucion!)}',
|
||||
)
|
||||
else
|
||||
const _NoticeLine(
|
||||
@@ -182,7 +208,8 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
const SizedBox(height: 6),
|
||||
_NoticeLine(
|
||||
icon: Icons.skip_next_rounded,
|
||||
text: 'Una ejecución fue omitida: ${_fechaHora(excepcion.ejecucion)}.',
|
||||
text:
|
||||
'Una ejecución fue omitida: ${_fechaHora(excepcion.ejecucion)}.',
|
||||
),
|
||||
],
|
||||
if (mensajeVacaciones != null) ...[
|
||||
@@ -203,31 +230,32 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.skip_next_rounded),
|
||||
label: const Text('Omitir siguiente'),
|
||||
onPressed: alarma.proximaEjecucion == null
|
||||
? null
|
||||
: () async {
|
||||
await estado.saltarProxima(alarma.id);
|
||||
if (context.mounted) {
|
||||
final alarmas =
|
||||
context.read<EstadoAlarmas>().alarmas;
|
||||
AlarmaMusical? actualizada;
|
||||
for (final item in alarmas) {
|
||||
if (item.id == alarma.id) {
|
||||
actualizada = item;
|
||||
break;
|
||||
onPressed:
|
||||
alarma.proximaEjecucion == null
|
||||
? null
|
||||
: () async {
|
||||
await estado.saltarProxima(alarma.id);
|
||||
if (context.mounted) {
|
||||
final alarmas =
|
||||
context.read<EstadoAlarmas>().alarmas;
|
||||
AlarmaMusical? actualizada;
|
||||
for (final item in alarmas) {
|
||||
if (item.id == alarma.id) {
|
||||
actualizada = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
actualizada?.proximaEjecucion == null
|
||||
? 'Alarma omitida. No queda próxima ejecución.'
|
||||
: 'Alarma omitida. Volverá el ${_fechaHora(actualizada!.proximaEjecucion!)}.',
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
actualizada?.proximaEjecucion == null
|
||||
? 'Alarma omitida. No queda próxima ejecución.'
|
||||
: 'Alarma omitida. Volverá el ${_fechaHora(actualizada!.proximaEjecucion!)}.',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
@@ -305,7 +333,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
_nombreController = TextEditingController(
|
||||
text: alarma?.nombre ?? 'Despertador musical',
|
||||
);
|
||||
_hora = TimeOfDay(hour: alarma?.hora ?? ahora.hour, minute: alarma?.minuto ?? ahora.minute);
|
||||
_hora = TimeOfDay(
|
||||
hour: alarma?.hora ?? ahora.hour,
|
||||
minute: alarma?.minuto ?? ahora.minute,
|
||||
);
|
||||
_fecha = alarma?.fechaUnica ?? ahora;
|
||||
_tipo = alarma?.tipoProgramacion ?? TipoProgramacionAlarma.unica;
|
||||
_diasSemana = {...alarma?.diasSemana ?? const <int>[]};
|
||||
@@ -332,7 +363,9 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
if (mounted) context.read<EstadoRadio>().cargarFavoritos();
|
||||
});
|
||||
}
|
||||
if (_emisora == null && widget.alarma == null && radio.emisoraPreferida != null) {
|
||||
if (_emisora == null &&
|
||||
widget.alarma == null &&
|
||||
radio.emisoraPreferida != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _emisora == null) {
|
||||
setState(() => _emisora = radio.emisoraPreferida);
|
||||
@@ -352,7 +385,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 58),
|
||||
const _AssetIcon(
|
||||
'assets/icons/alarmas/alarm_music.png',
|
||||
size: 58,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -390,7 +426,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
icon: Icons.event_rounded,
|
||||
label: 'Fecha',
|
||||
value: _fechaCorta(_fecha),
|
||||
onTap: _tipo == TipoProgramacionAlarma.unica ? _elegirFecha : null,
|
||||
onTap:
|
||||
_tipo == TipoProgramacionAlarma.unica
|
||||
? _elegirFecha
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -398,12 +437,22 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
const SizedBox(height: 12),
|
||||
SegmentedButton<TipoProgramacionAlarma>(
|
||||
segments: const [
|
||||
ButtonSegment(value: TipoProgramacionAlarma.unica, label: Text('Una vez')),
|
||||
ButtonSegment(value: TipoProgramacionAlarma.diaria, label: Text('Diaria')),
|
||||
ButtonSegment(value: TipoProgramacionAlarma.diasSemana, label: Text('Días')),
|
||||
ButtonSegment(
|
||||
value: TipoProgramacionAlarma.unica,
|
||||
label: Text('Una vez'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: TipoProgramacionAlarma.diaria,
|
||||
label: Text('Diaria'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: TipoProgramacionAlarma.diasSemana,
|
||||
label: Text('Días'),
|
||||
),
|
||||
],
|
||||
selected: {_tipo},
|
||||
onSelectionChanged: (value) => setState(() => _tipo = value.first),
|
||||
onSelectionChanged:
|
||||
(value) => setState(() => _tipo = value.first),
|
||||
),
|
||||
if (_tipo == TipoProgramacionAlarma.diasSemana) ...[
|
||||
const SizedBox(height: 10),
|
||||
@@ -414,15 +463,21 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
FilterChip(
|
||||
label: Text(_diaCorto(i)),
|
||||
selected: _diasSemana.contains(i),
|
||||
onSelected: (selected) => setState(() {
|
||||
selected ? _diasSemana.add(i) : _diasSemana.remove(i);
|
||||
}),
|
||||
onSelected:
|
||||
(selected) => setState(() {
|
||||
selected
|
||||
? _diasSemana.add(i)
|
||||
: _diasSemana.remove(i);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
_SectionLabel(icon: 'assets/icons/alarmas/snooze_wave.png', text: 'Postponer'),
|
||||
_SectionLabel(
|
||||
icon: 'assets/icons/alarmas/snooze_wave.png',
|
||||
text: 'Postponer',
|
||||
),
|
||||
SegmentedButton<int>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 3, label: Text('3 min')),
|
||||
@@ -430,10 +485,14 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
ButtonSegment(value: 10, label: Text('10 min')),
|
||||
],
|
||||
selected: {_snooze},
|
||||
onSelectionChanged: (value) => setState(() => _snooze = value.first),
|
||||
onSelectionChanged:
|
||||
(value) => setState(() => _snooze = value.first),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_SectionLabel(icon: 'assets/icons/alarmas/fallback_sound.png', text: 'Sonido y volumen'),
|
||||
_SectionLabel(
|
||||
icon: 'assets/icons/alarmas/fallback_sound.png',
|
||||
text: 'Sonido y volumen',
|
||||
),
|
||||
Slider(
|
||||
value: _volumen,
|
||||
min: 0.25,
|
||||
@@ -444,13 +503,27 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
),
|
||||
DropdownButtonFormField<SonidoInternoAlarma>(
|
||||
initialValue: _sonidoInterno,
|
||||
decoration: const InputDecoration(labelText: 'Sonido seguro interno'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Sonido seguro interno',
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: SonidoInternoAlarma.amanecer, child: Text('Amanecer cálido')),
|
||||
DropdownMenuItem(value: SonidoInternoAlarma.campanaSuave, child: Text('Campana suave')),
|
||||
DropdownMenuItem(value: SonidoInternoAlarma.pulsoDigital, child: Text('Pulso digital')),
|
||||
DropdownMenuItem(
|
||||
value: SonidoInternoAlarma.amanecer,
|
||||
child: Text('Amanecer cálido'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SonidoInternoAlarma.campanaSuave,
|
||||
child: Text('Campana suave'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SonidoInternoAlarma.pulsoDigital,
|
||||
child: Text('Pulso digital'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) => setState(() => _sonidoInterno = value ?? _sonidoInterno),
|
||||
onChanged:
|
||||
(value) => setState(
|
||||
() => _sonidoInterno = value ?? _sonidoInterno,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
@@ -474,13 +547,14 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (uuid) => setState(() {
|
||||
if (uuid == null || uuid.isEmpty) {
|
||||
_emisora = null;
|
||||
return;
|
||||
}
|
||||
_emisora = favoritas.firstWhere((e) => e.uuid == uuid);
|
||||
}),
|
||||
onChanged:
|
||||
(uuid) => setState(() {
|
||||
if (uuid == null || uuid.isEmpty) {
|
||||
_emisora = null;
|
||||
return;
|
||||
}
|
||||
_emisora = favoritas.firstWhere((e) => e.uuid == uuid);
|
||||
}),
|
||||
),
|
||||
if (favoritas.isEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
@@ -493,7 +567,8 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: () => setState(() => _emisora = radio.emisoraActual),
|
||||
onPressed:
|
||||
() => setState(() => _emisora = radio.emisoraActual),
|
||||
icon: const Icon(Icons.add_task_rounded),
|
||||
label: const Text('Usar emisora actual'),
|
||||
),
|
||||
@@ -503,10 +578,16 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _sonarEnVacaciones,
|
||||
onChanged: (value) => setState(() => _sonarEnVacaciones = value),
|
||||
secondary: const _AssetIcon('assets/icons/alarmas/vacation_wave.png', size: 42),
|
||||
onChanged:
|
||||
(value) => setState(() => _sonarEnVacaciones = value),
|
||||
secondary: const _AssetIcon(
|
||||
'assets/icons/alarmas/vacation_wave.png',
|
||||
size: 42,
|
||||
),
|
||||
title: const Text('Sonar durante vacaciones'),
|
||||
subtitle: const Text('Si lo apagás, la próxima ejecución saltará al primer día válido.'),
|
||||
subtitle: const Text(
|
||||
'Si lo apagás, la próxima ejecución saltará al primer día válido.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
@@ -556,24 +637,26 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
diasSemana: _diasSemana.toList()..sort(),
|
||||
))
|
||||
.copyWith(
|
||||
nombre: _nombreController.text.trim().isEmpty
|
||||
? 'Despertador musical'
|
||||
: _nombreController.text.trim(),
|
||||
hora: _hora.hour,
|
||||
minuto: _hora.minute,
|
||||
tipoProgramacion: _tipo,
|
||||
diasSemana: _tipo == TipoProgramacionAlarma.diasSemana
|
||||
? (_diasSemana.toList()..sort())
|
||||
: const [],
|
||||
fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
|
||||
limpiarFechaUnica: _tipo != TipoProgramacionAlarma.unica,
|
||||
emisora: _emisora,
|
||||
sonarEnVacaciones: _sonarEnVacaciones,
|
||||
snoozeMinutos: _snooze,
|
||||
volumen: _volumen,
|
||||
sonidoInterno: _sonidoInterno,
|
||||
activa: true,
|
||||
);
|
||||
nombre:
|
||||
_nombreController.text.trim().isEmpty
|
||||
? 'Despertador musical'
|
||||
: _nombreController.text.trim(),
|
||||
hora: _hora.hour,
|
||||
minuto: _hora.minute,
|
||||
tipoProgramacion: _tipo,
|
||||
diasSemana:
|
||||
_tipo == TipoProgramacionAlarma.diasSemana
|
||||
? (_diasSemana.toList()..sort())
|
||||
: const [],
|
||||
fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
|
||||
limpiarFechaUnica: _tipo != TipoProgramacionAlarma.unica,
|
||||
emisora: _emisora,
|
||||
sonarEnVacaciones: _sonarEnVacaciones,
|
||||
snoozeMinutos: _snooze,
|
||||
volumen: _volumen,
|
||||
sonidoInterno: _sonidoInterno,
|
||||
activa: true,
|
||||
);
|
||||
await estado.guardarAlarma(alarma);
|
||||
if (mounted) Navigator.pop(context);
|
||||
}
|
||||
@@ -600,13 +683,21 @@ class _AccesoDiagnostico extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final diag = estado.diagnostico;
|
||||
return TextButton.icon(
|
||||
icon: const _AssetIcon('assets/icons/alarmas/android_reliability.png', size: 28),
|
||||
icon: const _AssetIcon(
|
||||
'assets/icons/alarmas/android_reliability.png',
|
||||
size: 28,
|
||||
),
|
||||
label: Text(
|
||||
diag == null
|
||||
? 'Revisar fiabilidad Android'
|
||||
: 'Fiabilidad: exactas ${diag.puedeProgramarExactas ? 'OK' : 'pendiente'} · notificaciones ${diag.notificacionesPermitidas ? 'OK' : 'pendiente'}',
|
||||
),
|
||||
onPressed: estado.cargarDiagnostico,
|
||||
onPressed: () async {
|
||||
if (diag != null && !diag.puedeProgramarExactas) {
|
||||
await estado.android.solicitarPermisoAlarmasExactas();
|
||||
}
|
||||
await estado.cargarDiagnostico();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -627,7 +718,10 @@ class _PanelVacaciones extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const _AssetIcon('assets/icons/alarmas/vacation_wave.png', size: 48),
|
||||
const _AssetIcon(
|
||||
'assets/icons/alarmas/vacation_wave.png',
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -723,9 +817,9 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
children: [
|
||||
Text(
|
||||
'Nuevo rango de vacaciones',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
@@ -811,7 +905,8 @@ class _AssetIcon extends StatelessWidget {
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65),
|
||||
errorBuilder:
|
||||
(_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -857,7 +952,12 @@ class _SectionLabel extends StatelessWidget {
|
||||
children: [
|
||||
_AssetIcon(icon, size: 34),
|
||||
const SizedBox(width: 8),
|
||||
Text(text, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800)),
|
||||
Text(
|
||||
text,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -918,9 +1018,11 @@ String _hora(AlarmaMusical alarma) =>
|
||||
|
||||
String _programacion(AlarmaMusical alarma) {
|
||||
return switch (alarma.tipoProgramacion) {
|
||||
TipoProgramacionAlarma.unica => 'Una vez · ${_fechaCorta(alarma.fechaUnica ?? DateTime.now())}',
|
||||
TipoProgramacionAlarma.unica =>
|
||||
'Una vez · ${_fechaCorta(alarma.fechaUnica ?? DateTime.now())}',
|
||||
TipoProgramacionAlarma.diaria => 'Diaria',
|
||||
TipoProgramacionAlarma.diasSemana => 'Días: ${alarma.diasSemana.map(_diaCorto).join(', ')}',
|
||||
TipoProgramacionAlarma.diasSemana =>
|
||||
'Días: ${alarma.diasSemana.map(_diaCorto).join(', ')}',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -934,39 +1036,39 @@ String _fechaCorta(DateTime fecha) =>
|
||||
'${fecha.day.toString().padLeft(2, '0')}/${fecha.month.toString().padLeft(2, '0')}/${fecha.year}';
|
||||
|
||||
String _diaCorto(int dia) => switch (dia) {
|
||||
DateTime.monday => 'Lun',
|
||||
DateTime.tuesday => 'Mar',
|
||||
DateTime.wednesday => 'Mié',
|
||||
DateTime.thursday => 'Jue',
|
||||
DateTime.friday => 'Vie',
|
||||
DateTime.saturday => 'Sáb',
|
||||
DateTime.sunday => 'Dom',
|
||||
_ => '?',
|
||||
};
|
||||
DateTime.monday => 'Lun',
|
||||
DateTime.tuesday => 'Mar',
|
||||
DateTime.wednesday => 'Mié',
|
||||
DateTime.thursday => 'Jue',
|
||||
DateTime.friday => 'Vie',
|
||||
DateTime.saturday => 'Sáb',
|
||||
DateTime.sunday => 'Dom',
|
||||
_ => '?',
|
||||
};
|
||||
|
||||
String _diaLargo(int dia) => switch (dia) {
|
||||
DateTime.monday => 'lunes',
|
||||
DateTime.tuesday => 'martes',
|
||||
DateTime.wednesday => 'miércoles',
|
||||
DateTime.thursday => 'jueves',
|
||||
DateTime.friday => 'viernes',
|
||||
DateTime.saturday => 'sábado',
|
||||
DateTime.sunday => 'domingo',
|
||||
_ => 'día',
|
||||
};
|
||||
DateTime.monday => 'lunes',
|
||||
DateTime.tuesday => 'martes',
|
||||
DateTime.wednesday => 'miércoles',
|
||||
DateTime.thursday => 'jueves',
|
||||
DateTime.friday => 'viernes',
|
||||
DateTime.saturday => 'sábado',
|
||||
DateTime.sunday => 'domingo',
|
||||
_ => 'día',
|
||||
};
|
||||
|
||||
String _mes(int mes) => switch (mes) {
|
||||
1 => 'enero',
|
||||
2 => 'febrero',
|
||||
3 => 'marzo',
|
||||
4 => 'abril',
|
||||
5 => 'mayo',
|
||||
6 => 'junio',
|
||||
7 => 'julio',
|
||||
8 => 'agosto',
|
||||
9 => 'septiembre',
|
||||
10 => 'octubre',
|
||||
11 => 'noviembre',
|
||||
12 => 'diciembre',
|
||||
_ => 'mes',
|
||||
};
|
||||
1 => 'enero',
|
||||
2 => 'febrero',
|
||||
3 => 'marzo',
|
||||
4 => 'abril',
|
||||
5 => 'mayo',
|
||||
6 => 'junio',
|
||||
7 => 'julio',
|
||||
8 => 'agosto',
|
||||
9 => 'septiembre',
|
||||
10 => 'octubre',
|
||||
11 => 'noviembre',
|
||||
12 => 'diciembre',
|
||||
_ => 'mes',
|
||||
};
|
||||
|
||||
@@ -411,9 +411,15 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
? estado.detenerGrabacion
|
||||
: () => _mostrarDialogoGrabacion(context),
|
||||
),
|
||||
if (!activa)
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Abrir carpeta',
|
||||
icon: const Icon(Icons.folder_open_rounded),
|
||||
onPressed: () => _abrirCarpetaGrabaciones(context),
|
||||
),
|
||||
if (!activa && hayUltimaGrabacion)
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Abrir ?ltima grabaci?n',
|
||||
tooltip: 'Abrir última grabación',
|
||||
icon: const Icon(Icons.audio_file_rounded),
|
||||
onPressed: () => _abrirUltimaGrabacion(context),
|
||||
),
|
||||
@@ -430,7 +436,20 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
if (!context.mounted) return;
|
||||
if (!abierto) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('No se pudo abrir la ?ltima grabaci?n')),
|
||||
const SnackBar(content: Text('No se pudo abrir la última grabación')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _abrirCarpetaGrabaciones(BuildContext context) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final abierto = await estado.abrirDirectorioGrabacion();
|
||||
if (!context.mounted) return;
|
||||
if (!abierto) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No se pudo abrir la carpeta de grabaciones'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ class ServicioAlarmasAndroid {
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] programar id=${alarma.id} nombre=${alarma.nombre} proxima=${proxima.toIso8601String()} preaviso=${proxima.subtract(const Duration(minutes: 30)).toIso8601String()}',
|
||||
);
|
||||
await _channel.invokeMethod<void>('scheduleAlarm', {
|
||||
final programada = await _channel.invokeMethod<bool>('scheduleAlarm', {
|
||||
'id': alarma.id,
|
||||
'title': alarma.nombre,
|
||||
'triggerAtMillis': proxima.millisecondsSinceEpoch,
|
||||
@@ -85,6 +85,11 @@ class ServicioAlarmasAndroid {
|
||||
'fallbackSound': alarma.sonidoInterno.name,
|
||||
'volume': alarma.volumen,
|
||||
});
|
||||
if (programada != true) {
|
||||
throw StateError(
|
||||
'Android no pudo programar una alarma exacta. Revisa el permiso de alarmas exactas.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelar(String alarmaId) =>
|
||||
@@ -96,6 +101,13 @@ class ServicioAlarmasAndroid {
|
||||
Future<void> detenerSonidoNativo(String alarmaId) =>
|
||||
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
|
||||
|
||||
Future<bool> solicitarPermisoAlarmasExactas() async {
|
||||
final abierto = await _channel.invokeMethod<bool>(
|
||||
'requestExactAlarmPermission',
|
||||
);
|
||||
return abierto ?? false;
|
||||
}
|
||||
|
||||
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
|
||||
debugPrint('[PluriWave][alarmas] diagnostico android');
|
||||
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class NotaVersionPluri {
|
||||
const NotaVersionPluri({
|
||||
required this.version,
|
||||
required this.resumen,
|
||||
required this.markdown,
|
||||
});
|
||||
|
||||
final String version;
|
||||
final String resumen;
|
||||
final String markdown;
|
||||
}
|
||||
|
||||
class ContenidoAyudaPluri {
|
||||
const ContenidoAyudaPluri({
|
||||
required this.onboarding,
|
||||
required this.notas,
|
||||
required this.versionActual,
|
||||
});
|
||||
|
||||
final String onboarding;
|
||||
final List<NotaVersionPluri> notas;
|
||||
final String versionActual;
|
||||
}
|
||||
|
||||
class ServicioContenidoApp {
|
||||
static const _keyOnboardingVisto = 'pluri_onboarding_visto_v1';
|
||||
static const _keyVersionVista = 'pluri_ultima_version_novedades_v1';
|
||||
static const _versiones = ['0.1.47'];
|
||||
|
||||
Future<bool> debeMostrarInicio() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final versionActual = info.version;
|
||||
return !(prefs.getBool(_keyOnboardingVisto) ?? false) ||
|
||||
prefs.getString(_keyVersionVista) != versionActual;
|
||||
}
|
||||
|
||||
Future<void> marcarVisto() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
await prefs.setBool(_keyOnboardingVisto, true);
|
||||
await prefs.setString(_keyVersionVista, info.version);
|
||||
}
|
||||
|
||||
Future<ContenidoAyudaPluri> cargar(
|
||||
String codigoIdioma, {
|
||||
bool soloPendientes = false,
|
||||
}) async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final ultimaVista = prefs.getString(_keyVersionVista);
|
||||
final idioma = _idiomaSoportado(codigoIdioma);
|
||||
final mostrarOnboarding =
|
||||
!soloPendientes || !(prefs.getBool(_keyOnboardingVisto) ?? false);
|
||||
final onboarding =
|
||||
mostrarOnboarding
|
||||
? await _cargarMarkdown(
|
||||
'assets/content/onboarding/$idioma.md',
|
||||
fallback: 'assets/content/onboarding/en.md',
|
||||
)
|
||||
: '';
|
||||
final notas = <NotaVersionPluri>[];
|
||||
for (final version in _versiones) {
|
||||
if (soloPendientes &&
|
||||
ultimaVista != null &&
|
||||
_compararVersiones(version, ultimaVista.split('+').first) <= 0) {
|
||||
continue;
|
||||
}
|
||||
final markdown = await _cargarMarkdown(
|
||||
'assets/content/updates/$idioma/$version.md',
|
||||
fallback: 'assets/content/updates/en/$version.md',
|
||||
);
|
||||
if (markdown.trim().isEmpty) continue;
|
||||
notas.add(
|
||||
NotaVersionPluri(
|
||||
version: version,
|
||||
resumen: _resumen(markdown),
|
||||
markdown: markdown,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ContenidoAyudaPluri(
|
||||
onboarding: onboarding,
|
||||
notas: notas,
|
||||
versionActual: _versionCompleta(info),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _cargarMarkdown(
|
||||
String path, {
|
||||
required String fallback,
|
||||
}) async {
|
||||
try {
|
||||
return await rootBundle.loadString(path);
|
||||
} catch (_) {
|
||||
return rootBundle.loadString(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
String _idiomaSoportado(String codigo) {
|
||||
const soportados = {
|
||||
'ar',
|
||||
'bn',
|
||||
'de',
|
||||
'en',
|
||||
'es',
|
||||
'fr',
|
||||
'hi',
|
||||
'id',
|
||||
'it',
|
||||
'ja',
|
||||
'pt',
|
||||
'ru',
|
||||
'zh',
|
||||
};
|
||||
return soportados.contains(codigo) ? codigo : 'en';
|
||||
}
|
||||
|
||||
String _resumen(String markdown) {
|
||||
for (final line in markdown.split('\n')) {
|
||||
final limpia = line.trim();
|
||||
if (limpia.toLowerCase().startsWith('resumen:')) {
|
||||
return limpia.substring(limpia.indexOf(':') + 1).trim();
|
||||
}
|
||||
if (limpia.toLowerCase().startsWith('summary:')) {
|
||||
return limpia.substring(limpia.indexOf(':') + 1).trim();
|
||||
}
|
||||
if (limpia.contains(':')) {
|
||||
final etiqueta = limpia.substring(0, limpia.indexOf(':')).toLowerCase();
|
||||
const etiquetasResumen = {
|
||||
'résumé',
|
||||
'zusammenfassung',
|
||||
'riepilogo',
|
||||
'resumo',
|
||||
'ملخص',
|
||||
'সারাংশ',
|
||||
'सारांश',
|
||||
'ringkasan',
|
||||
'概要',
|
||||
'摘要',
|
||||
'резюме',
|
||||
};
|
||||
if (etiquetasResumen.contains(etiqueta)) {
|
||||
return limpia.substring(limpia.indexOf(':') + 1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String _versionCompleta(PackageInfo info) =>
|
||||
'${info.version}+${info.buildNumber}';
|
||||
|
||||
int _compararVersiones(String a, String b) {
|
||||
final pa = a.split('.').map((e) => int.tryParse(e) ?? 0).toList();
|
||||
final pb = b.split('.').map((e) => int.tryParse(e) ?? 0).toList();
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final va = i < pa.length ? pa[i] : 0;
|
||||
final vb = i < pb.length ? pb[i] : 0;
|
||||
if (va != vb) return va.compareTo(vb);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import '../modelos/alarma_musical.dart';
|
||||
|
||||
class ServicioProgramacionAlarmas {
|
||||
static const Duration toleranciaDisparoInminente = Duration(seconds: 90);
|
||||
|
||||
DateTime? calcularProxima({
|
||||
required AlarmaMusical alarma,
|
||||
required DateTime desde,
|
||||
@@ -24,25 +26,27 @@ class ServicioProgramacionAlarmas {
|
||||
final primerCandidato =
|
||||
alarma.tipoProgramacion == TipoProgramacionAlarma.unica
|
||||
? inicio
|
||||
: inicio.isAfter(desde)
|
||||
: _sigueSiendoInminente(inicio, desde)
|
||||
? inicio
|
||||
: inicio.add(const Duration(days: 1));
|
||||
|
||||
return switch (alarma.tipoProgramacion) {
|
||||
TipoProgramacionAlarma.unica =>
|
||||
primerCandidato.isAfter(desde) &&
|
||||
_sigueSiendoInminente(primerCandidato, desde) &&
|
||||
_esValida(alarma, primerCandidato, vacaciones, excepciones)
|
||||
? primerCandidato
|
||||
? _normalizarInminente(primerCandidato, desde)
|
||||
: null,
|
||||
TipoProgramacionAlarma.diaria => _buscarDiaria(
|
||||
alarma,
|
||||
primerCandidato,
|
||||
desde,
|
||||
vacaciones,
|
||||
excepciones,
|
||||
),
|
||||
TipoProgramacionAlarma.diasSemana => _buscarPorDiasSemana(
|
||||
alarma,
|
||||
primerCandidato,
|
||||
desde,
|
||||
vacaciones,
|
||||
excepciones,
|
||||
),
|
||||
@@ -60,12 +64,16 @@ class ServicioProgramacionAlarmas {
|
||||
DateTime? _buscarDiaria(
|
||||
AlarmaMusical alarma,
|
||||
DateTime candidato,
|
||||
DateTime desde,
|
||||
List<RangoVacaciones> vacaciones,
|
||||
List<ExcepcionAlarma> excepciones,
|
||||
) {
|
||||
var actual = candidato;
|
||||
for (var i = 0; i < 370; i++) {
|
||||
if (_esValida(alarma, actual, vacaciones, excepciones)) return actual;
|
||||
if (_sigueSiendoInminente(actual, desde) &&
|
||||
_esValida(alarma, actual, vacaciones, excepciones)) {
|
||||
return _normalizarInminente(actual, desde);
|
||||
}
|
||||
actual = actual.add(const Duration(days: 1));
|
||||
}
|
||||
return null;
|
||||
@@ -74,6 +82,7 @@ class ServicioProgramacionAlarmas {
|
||||
DateTime? _buscarPorDiasSemana(
|
||||
AlarmaMusical alarma,
|
||||
DateTime candidato,
|
||||
DateTime desde,
|
||||
List<RangoVacaciones> vacaciones,
|
||||
List<ExcepcionAlarma> excepciones,
|
||||
) {
|
||||
@@ -81,8 +90,9 @@ class ServicioProgramacionAlarmas {
|
||||
var actual = candidato;
|
||||
for (var i = 0; i < 370; i++) {
|
||||
if (alarma.diasSemana.contains(actual.weekday) &&
|
||||
_sigueSiendoInminente(actual, desde) &&
|
||||
_esValida(alarma, actual, vacaciones, excepciones)) {
|
||||
return actual;
|
||||
return _normalizarInminente(actual, desde);
|
||||
}
|
||||
actual = actual.add(const Duration(days: 1));
|
||||
}
|
||||
@@ -111,4 +121,13 @@ class ServicioProgramacionAlarmas {
|
||||
a.day == b.day &&
|
||||
a.hour == b.hour &&
|
||||
a.minute == b.minute;
|
||||
|
||||
bool _sigueSiendoInminente(DateTime candidato, DateTime desde) =>
|
||||
candidato.isAfter(desde) ||
|
||||
desde.difference(candidato) <= toleranciaDisparoInminente;
|
||||
|
||||
DateTime _normalizarInminente(DateTime candidato, DateTime desde) =>
|
||||
candidato.isAfter(desde)
|
||||
? candidato
|
||||
: desde.add(const Duration(seconds: 2));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PluriMarkdown extends StatelessWidget {
|
||||
const PluriMarkdown(this.markdown, {super.key});
|
||||
|
||||
final String markdown;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final widgets = <Widget>[];
|
||||
for (final raw in markdown.split('\n')) {
|
||||
final line = raw.trimRight();
|
||||
if (line.trim().isEmpty) {
|
||||
widgets.add(const SizedBox(height: 8));
|
||||
} else if (line.startsWith('# ')) {
|
||||
widgets.add(_Heading(line.substring(2), level: 1));
|
||||
} else if (line.startsWith('## ')) {
|
||||
widgets.add(_Heading(line.substring(3), level: 2));
|
||||
} else if (line.startsWith('- ')) {
|
||||
widgets.add(_Bullet(line.substring(2)));
|
||||
} else if (!line.toLowerCase().startsWith('resumen:') &&
|
||||
!line.toLowerCase().startsWith('summary:')) {
|
||||
widgets.add(_Paragraph(line));
|
||||
}
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: widgets,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Heading extends StatelessWidget {
|
||||
const _Heading(this.text, {required this.level});
|
||||
|
||||
final String text;
|
||||
final int level;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: level == 1 ? 0 : 14, bottom: 8),
|
||||
child: Text(
|
||||
text,
|
||||
style: (level == 1
|
||||
? theme.textTheme.headlineSmall
|
||||
: theme.textTheme.titleMedium)
|
||||
?.copyWith(fontWeight: FontWeight.w900),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Paragraph extends StatelessWidget {
|
||||
const _Paragraph(this.text);
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(text, style: Theme.of(context).textTheme.bodyMedium),
|
||||
);
|
||||
}
|
||||
|
||||
class _Bullet extends StatelessWidget {
|
||||
const _Bullet(this.text);
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_awesome_rounded,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(text)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../servicios/servicio_contenido_app.dart';
|
||||
import 'pluri_glass_surface.dart';
|
||||
import 'pluri_markdown.dart';
|
||||
|
||||
class PluriOnboardingDialog {
|
||||
PluriOnboardingDialog._();
|
||||
|
||||
static final _servicio = ServicioContenidoApp();
|
||||
|
||||
static Future<void> mostrarSiProcede(BuildContext context) async {
|
||||
if (!await _servicio.debeMostrarInicio()) return;
|
||||
if (!context.mounted) return;
|
||||
await mostrar(context, soloPendientes: true);
|
||||
await _servicio.marcarVisto();
|
||||
}
|
||||
|
||||
static Future<void> mostrar(
|
||||
BuildContext context, {
|
||||
bool soloPendientes = false,
|
||||
}) async {
|
||||
final idioma = Localizations.localeOf(context).languageCode;
|
||||
final contenido = await _servicio.cargar(
|
||||
idioma,
|
||||
soloPendientes: soloPendientes,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _PluriOnboardingContent(contenido: contenido),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PluriOnboardingContent extends StatelessWidget {
|
||||
const _PluriOnboardingContent({required this.contenido});
|
||||
|
||||
final ContenidoAyudaPluri contenido;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labels = _labels(Localizations.localeOf(context).languageCode);
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
backgroundColor: Colors.transparent,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 720,
|
||||
maxHeight: size.height * 0.86,
|
||||
),
|
||||
child: PluriGlassSurface(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
glowColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.28),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 54,
|
||||
height: 54,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.primary,
|
||||
Theme.of(context).colorScheme.tertiary,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.graphic_eq_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
labels.title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: labels.close,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
if (contenido.onboarding.trim().isNotEmpty)
|
||||
PluriMarkdown(contenido.onboarding),
|
||||
if (contenido.notas.isNotEmpty) ...[
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
labels.news,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final nota in contenido.notas)
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
title: Text('v${nota.version}'),
|
||||
subtitle:
|
||||
nota.resumen.isEmpty ? null : Text(nota.resumen),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: PluriMarkdown(nota.markdown),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.check_rounded),
|
||||
label: Text(labels.start),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_OnboardingLabels _labels(String languageCode) {
|
||||
return switch (languageCode) {
|
||||
'es' => const _OnboardingLabels(
|
||||
title: 'Bienvenido a PluriWave',
|
||||
news: 'Novedades',
|
||||
start: 'Empezar',
|
||||
close: 'Cerrar',
|
||||
),
|
||||
'fr' => const _OnboardingLabels(
|
||||
title: 'Bienvenue sur PluriWave',
|
||||
news: 'Nouveautés',
|
||||
start: 'Commencer',
|
||||
close: 'Fermer',
|
||||
),
|
||||
'de' => const _OnboardingLabels(
|
||||
title: 'Willkommen bei PluriWave',
|
||||
news: 'Neuigkeiten',
|
||||
start: 'Starten',
|
||||
close: 'Schließen',
|
||||
),
|
||||
'it' => const _OnboardingLabels(
|
||||
title: 'Benvenuto in PluriWave',
|
||||
news: 'Novità',
|
||||
start: 'Inizia',
|
||||
close: 'Chiudi',
|
||||
),
|
||||
'pt' => const _OnboardingLabels(
|
||||
title: 'Bem-vindo ao PluriWave',
|
||||
news: 'Novidades',
|
||||
start: 'Começar',
|
||||
close: 'Fechar',
|
||||
),
|
||||
_ => const _OnboardingLabels(
|
||||
title: 'Welcome to PluriWave',
|
||||
news: 'What’s new',
|
||||
start: 'Start',
|
||||
close: 'Close',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
class _OnboardingLabels {
|
||||
const _OnboardingLabels({
|
||||
required this.title,
|
||||
required this.news,
|
||||
required this.start,
|
||||
required this.close,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String news;
|
||||
final String start;
|
||||
final String close;
|
||||
}
|
||||
Reference in New Issue
Block a user