Adds a permanent, non-consumable premium unlock (EstadoEntitlement + PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks alarm vacations, alarms past a 5-alarm free cap, recording start, and full Android Auto browsing. The phone equalizer stays free for everyone. - Entitlement is prefs-backed (compra_premium_v1), fail-open, and resolvable headlessly via esPremiumPersistido() for the Android Auto audio handler, which registers before runApp. - Android Auto reduced mode keeps the real root folder labels for free users; browsing into any of them (and playFromMediaId/playFromSearch/ skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio choke points, with a locked "Función Premium" item as the backstop. Current-station play/pause/stop stays untouched. A free -> premium transition actively invalidates the head unit's cached browse tree. - Ads (top banner + capped interstitial before adding a station or an alarm) are gated behind entitlement via ServicioAnuncios, using official Google test ad unit IDs pending AdMob provisioning. - Alarm cap UX shows an explanatory message with a secondary unlock action rather than a bare paywall jump; existing data is grandfathered. - 4 new localization keys translated across all 13 supported locales. Co-located tests use strict TDD (RED test before implementation) for every new pure-logic unit; full existing suite passes unchanged.
1912 lines
72 KiB
Dart
1912 lines
72 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../estado/estado_alarmas.dart';
|
|
import '../estado/estado_radio.dart';
|
|
import '../l10n/display_names.dart';
|
|
import '../l10n/formato_fechas.dart';
|
|
import '../l10n/app_localizations_ext.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/alarma_musical.dart';
|
|
import '../modelos/emisora.dart';
|
|
import '../servicios/servicio_anuncios.dart';
|
|
import '../servicios/servicio_programacion_alarmas.dart';
|
|
import '../tema/pluriwave_theme.dart';
|
|
import '../tema/pluriwave_tokens.dart';
|
|
import '../widgets/editor_hora_inline.dart';
|
|
import '../widgets/hoja_premium.dart';
|
|
import '../widgets/pluri_glass_surface.dart';
|
|
import '../widgets/pluri_layout.dart';
|
|
import '../widgets/pluri_push_scaffold.dart';
|
|
import '../widgets/pluri_root_header.dart';
|
|
import '../widgets/pluri_sleep_timer_sheet.dart';
|
|
import 'pantalla_diagnostico_alarmas.dart';
|
|
import 'pantalla_vacaciones.dart';
|
|
|
|
class PantallaAlarmas extends StatelessWidget {
|
|
const PantallaAlarmas({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final estado = context.watch<EstadoAlarmas>();
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: estado.refrescarProgramacion,
|
|
child: ListView(
|
|
padding: PluriLayout.pageListPadding,
|
|
children: [
|
|
// S1 (Tier 1 visual fidelity): the prototype has no global
|
|
// AppBar — this root now draws its own 56px title row instead of
|
|
// relying on app.dart's removed shared chrome (which is also
|
|
// where the sleep-timer action used to live).
|
|
// S2 (Tier 1 visual fidelity): PluriScreenHeader (the glass hero
|
|
// this used to be) is retired — it is not in the prototype at
|
|
// all. Its ONE functional bit, the create-alarm action, moves
|
|
// into PluriRootHeader's `actions` slot so it stays reachable.
|
|
PluriRootHeader(
|
|
title: l10n.alarmScreenTitle,
|
|
onSleepTimer: () => showPluriSleepTimerSheet(context),
|
|
actions: [
|
|
// Audit 7.1 (t4:325): a solid brand-teal pill with a plain
|
|
// `add` glyph -- was a tonal button with `auto_awesome`.
|
|
FilledButton.icon(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: PluriWaveTokens.brand,
|
|
foregroundColor: const Color(0xFF062126),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 14,
|
|
vertical: 9,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
textStyle: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
onPressed: () => _abrirEditor(context),
|
|
icon: const Icon(Icons.add_rounded, size: 18),
|
|
label: Text(l10n.createAlarmAction),
|
|
),
|
|
],
|
|
),
|
|
Padding(
|
|
padding: PluriLayout.pageContentPadding,
|
|
child: Column(
|
|
children: [
|
|
_PanelProximaAlarma(estado: estado),
|
|
const SizedBox(height: 12),
|
|
if (estado.alarmas.isEmpty)
|
|
const _EmptyAlarmas()
|
|
else
|
|
for (final alarma in estado.alarmas) ...[
|
|
_TarjetaAlarma(alarma: alarma),
|
|
// 7.5 (Tier 4 visual fidelity): the prototype's gap
|
|
// between stacked alarm cards is 10px (t4 line 334),
|
|
// not 12. Keyed per alarm id so the guard test can
|
|
// target the exact gap between two known cards.
|
|
SizedBox(
|
|
height: 10,
|
|
key: ValueKey('alarm-card-gap-${alarma.id}'),
|
|
),
|
|
],
|
|
_PanelVacaciones(estado: estado),
|
|
const SizedBox(height: 12),
|
|
_AccesoDiagnostico(estado: estado),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _abrirEditor(
|
|
BuildContext context, {
|
|
AlarmaMusical? alarma,
|
|
}) async {
|
|
// ADR-6 ordering (design.md): for a genuinely NEW alarm (no [alarma]),
|
|
// the cap-check + maybe-interstitial happen HERE, before the editor
|
|
// ever opens — "puedeCrearAlarma -> if false, show the limit message
|
|
// and no ad; if true, maybe-interstitial, then open the editor".
|
|
// Editing an existing alarm skips both checks entirely: it is never
|
|
// capped and never triggers the interstitial.
|
|
if (alarma == null) {
|
|
final estado = context.read<EstadoAlarmas>();
|
|
if (!estado.puedeCrearAlarma()) {
|
|
_mostrarLimiteAlarmas(context);
|
|
return;
|
|
}
|
|
await context.read<ServicioAnuncios>().intentarInterstitial();
|
|
if (!context.mounted) return;
|
|
}
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) => _EditorAlarmaSheet(alarma: alarma),
|
|
);
|
|
}
|
|
|
|
/// Freemium-gating spec "Alarm Cap UX Never Bare-Jumps To Paywall": an
|
|
/// explanatory message with a SECONDARY unlock action — never a direct
|
|
/// paywall navigation as the sole response to hitting the cap.
|
|
void _mostrarLimiteAlarmas(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(l10n.limiteAlarmasAlcanzado),
|
|
action: SnackBarAction(
|
|
label: l10n.desbloquearPremium,
|
|
onPressed: () => mostrarHojaPremium(context),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PanelProximaAlarma extends StatelessWidget {
|
|
const _PanelProximaAlarma({required this.estado});
|
|
|
|
final EstadoAlarmas estado;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final tokens = context.pluriTokens;
|
|
final proxima = estado.proximaAlarma;
|
|
final activasSinProxima =
|
|
estado.alarmas
|
|
.where((a) => a.activa && a.proximaProgramable == null)
|
|
.length;
|
|
final proximaProgramable = proxima?.proximaProgramable;
|
|
|
|
// Audit 7.2 (t4:326-330): warmCoral-tinted card, `alarm_on` icon at
|
|
// 26px, and the "Saltar" chip BESIDE the text on the same row -- was
|
|
// an opaque default card with a 72px PNG and the skip action stacked
|
|
// BELOW the text as an OutlinedButton.
|
|
return DecoratedBox(
|
|
key: const ValueKey('next-alarm-banner'),
|
|
decoration: BoxDecoration(
|
|
color: tokens.warmCoral.withValues(alpha: 0.13),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.34)),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.alarm_on, size: 26, color: tokens.warmCoral),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
proxima == null
|
|
? activasSinProxima > 0
|
|
? l10n.activeAlarmsWithoutNextTitle
|
|
: l10n.noActiveAlarms
|
|
: l10n.nextAlarmTitle,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
proxima == null
|
|
? activasSinProxima > 0
|
|
? l10n.activeAlarmsWithoutNextSubtitle(
|
|
activasSinProxima,
|
|
)
|
|
: l10n.createAlarmHint
|
|
: '${_nombreVisibleAlarma(l10n, proxima)} · ${_fechaHora(l10n, proximaProgramable!)}',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (proxima != null) ...[
|
|
const SizedBox(width: 8),
|
|
_ChipSaltar(onTap: () => _saltarDesdeHero(context, proxima)),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Audit 7.2 (t4:329): `padding:8px 12px;radius:10;rgba(255,255,255,.08)` --
|
|
/// plain text, no icon, unlike the previous `OutlinedButton.icon`.
|
|
class _ChipSaltar extends StatelessWidget {
|
|
const _ChipSaltar({required this.onTap});
|
|
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
return Material(
|
|
color: Colors.white.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: InkWell(
|
|
key: const ValueKey('hero-skip-next'),
|
|
borderRadius: BorderRadius.circular(10),
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
child: Text(
|
|
l10n.alarmHeroSkipAction,
|
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w800),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Hero banner inline skip (native-alarms delta, WU8): skips the featured
|
|
/// (soonest-firing) alarm via the SAME `saltarProxima` path the old
|
|
/// always-visible per-card skip button used to call — only the trigger
|
|
/// location moved.
|
|
Future<void> _saltarDesdeHero(
|
|
BuildContext context,
|
|
AlarmaMusical proxima,
|
|
) async {
|
|
final l10n = AppLocalizations.of(context);
|
|
final estado = context.read<EstadoAlarmas>();
|
|
await estado.saltarProxima(proxima.id);
|
|
if (!context.mounted) return;
|
|
final actualizada = context.read<EstadoAlarmas>().alarmas.firstWhere(
|
|
(item) => item.id == proxima.id,
|
|
orElse: () => proxima,
|
|
);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
actualizada.proximaProgramable == null
|
|
? l10n.alarmSkippedNoNextSnackbar
|
|
: l10n.alarmSkippedReturnsSnackbar(
|
|
_fechaHora(l10n, actualizada.proximaProgramable!),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Simplified alarm card (native-alarms delta, WU8): giant time + station +
|
|
/// switch, no always-visible action row. Edit/skip/delete are NEVER lost —
|
|
/// they move behind gestures: tap opens the editor, swipe deletes (with
|
|
/// confirmation), skip lives on the hero banner (`_PanelProximaAlarma`)
|
|
/// instead of a per-card button.
|
|
class _TarjetaAlarma extends StatelessWidget {
|
|
const _TarjetaAlarma({required this.alarma});
|
|
|
|
final AlarmaMusical alarma;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final estado = context.watch<EstadoAlarmas>();
|
|
final tokens = context.pluriTokens;
|
|
final estacion =
|
|
alarma.emisora == null
|
|
? l10n.noStationUseInternalSound
|
|
: localizedStationName(l10n, alarma.emisora!.nombre);
|
|
|
|
// Item 5: surfaces the genuinely useful fields that already exist on
|
|
// the model, WITHOUT turning the row into clutter -- each is shown
|
|
// only when it is a meaningful deviation from the common case.
|
|
// Mirrors EXACTLY the pause predicate `impactoDeRango`/
|
|
// `ServicioProgramacionAlarmas` already use
|
|
// (`!sonarEnVacaciones` while `activa`), gated by whether a vacation
|
|
// range is CURRENTLY active -- an alarm configured to pause but with
|
|
// no active range right now is not actually paused by anything yet.
|
|
final pausadaPorVacaciones =
|
|
alarma.activa &&
|
|
!alarma.sonarEnVacaciones &&
|
|
estado.rangoVacacionesActivo() != null;
|
|
final detalles = <String>[
|
|
if (alarma.fadeInSegundos > 0)
|
|
l10n.alarmFadeInLabel(alarma.fadeInSegundos),
|
|
if ((alarma.volumen * 100).round() != 85)
|
|
'${(alarma.volumen * 100).round()}%',
|
|
if (pausadaPorVacaciones) l10n.alarmCardVacationPausedBadge,
|
|
];
|
|
|
|
// fix/alarmas-fallos-silenciosos: `ultimaExcepcionPara` existed but was
|
|
// never read from any screen, so a failed native scheduling attempt (main
|
|
// alarm, pre-notice, foreground service, or a post-boot reschedule)
|
|
// rendered exactly like a healthy alarm -- switched on, no visible sign
|
|
// anything was wrong. `_esValida` only ever treats `tipoSaltoSiguiente`
|
|
// as a real skip, so any OTHER tipo found here is a reliability failure,
|
|
// never a deliberate user action.
|
|
final ultimaExcepcion = estado.ultimaExcepcionPara(alarma.id);
|
|
final fallo =
|
|
ultimaExcepcion != null &&
|
|
ExcepcionAlarma.tiposFallo.contains(ultimaExcepcion.tipo)
|
|
? ultimaExcepcion
|
|
: null;
|
|
|
|
return Dismissible(
|
|
key: ValueKey('tarjeta-alarma-${alarma.id}'),
|
|
direction: DismissDirection.horizontal,
|
|
background: const _FondoSwipeEliminarAlarma(
|
|
alignment: Alignment.centerLeft,
|
|
),
|
|
secondaryBackground: const _FondoSwipeEliminarAlarma(
|
|
alignment: Alignment.centerRight,
|
|
),
|
|
confirmDismiss: (_) => _confirmarEliminarAlarma(context, l10n),
|
|
onDismissed: (_) => estado.eliminarAlarma(alarma.id),
|
|
child: PluriGlassSurface(
|
|
glowColor: tokens.electricMagenta.withValues(alpha: 0.22),
|
|
padding: EdgeInsets.zero,
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
|
onTap: () => _abrirEditor(context, alarma: alarma),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Audit 7.4 (t4:339): the recurrence label sits on
|
|
// the SAME baseline as the giant time -- it used to
|
|
// be missing entirely.
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
|
textBaseline: TextBaseline.alphabetic,
|
|
children: [
|
|
Text(
|
|
_hora(alarma),
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.displaySmall?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
letterSpacing: -1.5,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
// Item 5: real day list can run longer than the
|
|
// old generic "Días" label -- Flexible+ellipsis
|
|
// keeps a long selection from overflowing the
|
|
// Row instead of clipping visibly.
|
|
Flexible(
|
|
child: Text(
|
|
_recurrenciaCorta(l10n, alarma),
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
color: Theme.of(context).colorScheme.onSurface
|
|
.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 7),
|
|
// Audit 7.4 (t4:341): a small station-art slot
|
|
// inline with the name -- was the name alone.
|
|
// `Emisora.favicon` is a network URL (the same
|
|
// hazard documented for the ringing screen's audit
|
|
// 9.2 -- `Image.network` here would hang widget
|
|
// tests without a mocked HttpClient), so this is a
|
|
// themed fallback icon, not the real per-station
|
|
// artwork, mirroring the recordings-row precedent
|
|
// (`pantalla_grabaciones.dart`'s `_FilaGrabacion`).
|
|
Row(
|
|
children: [
|
|
if (alarma.emisora != null) ...[
|
|
DecoratedBox(
|
|
key: const ValueKey('tarjeta-alarma-arte'),
|
|
decoration: BoxDecoration(
|
|
color: tokens.listSurface,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: Icon(
|
|
Icons.radio_rounded,
|
|
size: 14,
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withValues(alpha: 0.75),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 7),
|
|
],
|
|
Flexible(
|
|
child: Text(
|
|
estacion,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: Theme.of(context).colorScheme.onSurface
|
|
.withValues(alpha: 0.75),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// Item 5: fade/volume/vacation-pause state, only
|
|
// when each is a genuinely useful deviation from
|
|
// the common case (see `detalles` above) -- a
|
|
// single compact line, not a badge per field.
|
|
if (detalles.isNotEmpty) ...[
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
detalles.join(' · '),
|
|
key: ValueKey(
|
|
'tarjeta-alarma-detalles-${alarma.id}',
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurface.withValues(alpha: 0.55),
|
|
),
|
|
),
|
|
],
|
|
if (fallo != null) ...[
|
|
const SizedBox(height: 6),
|
|
_AvisoFalloProgramacion(
|
|
alarmaId: alarma.id,
|
|
esSoloPreaviso:
|
|
fallo.tipo == ExcepcionAlarma.tipoFalloPreaviso,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Switch.adaptive(
|
|
value: alarma.activa,
|
|
onChanged: (value) => estado.cambiarActiva(alarma, value),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _abrirEditor(BuildContext context, {required AlarmaMusical alarma}) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) => _EditorAlarmaSheet(alarma: alarma),
|
|
);
|
|
}
|
|
|
|
Future<bool> _confirmarEliminarAlarma(
|
|
BuildContext context,
|
|
AppLocalizations l10n,
|
|
) async {
|
|
final confirmado = await showDialog<bool>(
|
|
context: context,
|
|
builder:
|
|
(ctx) => AlertDialog(
|
|
title: Text(l10n.alarmDeleteConfirmTitle),
|
|
content: Text(l10n.alarmDeleteConfirmMessage),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(l10n.cancelAction),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(l10n.deleteAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return confirmado ?? false;
|
|
}
|
|
}
|
|
|
|
/// Per-alarm scheduling-failure notice (fix/alarmas-fallos-silenciosos):
|
|
/// renders INSIDE the card's own content, in its own small tap target --
|
|
/// the surrounding card `InkWell` (tap = edit) and `Dismissible` (swipe =
|
|
/// delete) are untouched; this inner `InkWell` only claims its own region
|
|
/// and pushes the diagnostics screen instead of opening the editor.
|
|
class _AvisoFalloProgramacion extends StatelessWidget {
|
|
const _AvisoFalloProgramacion({
|
|
required this.alarmaId,
|
|
required this.esSoloPreaviso,
|
|
});
|
|
|
|
final String alarmaId;
|
|
|
|
/// True when only the pre-notice reminder failed (the alarm itself is
|
|
/// still scheduled) -- the user's reported symptom explicitly called out
|
|
/// a missing pre-notice as distinct from the alarm never ringing at all,
|
|
/// so the message must not conflate the two.
|
|
final bool esSoloPreaviso;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final color = Theme.of(context).colorScheme.error;
|
|
return Material(
|
|
type: MaterialType.transparency,
|
|
child: InkWell(
|
|
key: ValueKey('tarjeta-alarma-fallo-$alarmaId'),
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: () => _abrirDiagnostico(context),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(Icons.warning_amber_rounded, size: 15, color: color),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
esSoloPreaviso
|
|
? l10n.alarmCardPreNoticeFailedMessage
|
|
: l10n.alarmCardSchedulingFailedMessage,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: color,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
l10n.androidReliabilityReview,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w800,
|
|
color: color,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _abrirDiagnostico(BuildContext context) {
|
|
PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas());
|
|
}
|
|
}
|
|
|
|
/// Swipe-to-delete reveal, shown on both sides so either swipe direction
|
|
/// works regardless of locale text direction.
|
|
class _FondoSwipeEliminarAlarma extends StatelessWidget {
|
|
const _FondoSwipeEliminarAlarma({required this.alignment});
|
|
|
|
final Alignment alignment;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = context.pluriTokens;
|
|
return Container(
|
|
alignment: alignment,
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.error,
|
|
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
|
),
|
|
child: Icon(
|
|
Icons.delete_outline_rounded,
|
|
color: Theme.of(context).colorScheme.onError,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EditorAlarmaSheet extends StatefulWidget {
|
|
const _EditorAlarmaSheet({this.alarma});
|
|
|
|
final AlarmaMusical? alarma;
|
|
|
|
@override
|
|
State<_EditorAlarmaSheet> createState() => _EditorAlarmaSheetState();
|
|
}
|
|
|
|
class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|
TextEditingController? _nombreController;
|
|
late TimeOfDay _hora;
|
|
late DateTime _fecha;
|
|
late TipoProgramacionAlarma _tipo;
|
|
late Set<int> _diasSemana;
|
|
late double _volumen;
|
|
late int _fadeInSegundos;
|
|
late int _snoozeMinutos;
|
|
late bool _sonarEnVacaciones;
|
|
late SonidoInternoAlarma _sonidoInterno;
|
|
Emisora? _emisora;
|
|
Emisora? _emisoraFallback;
|
|
bool _favoritosSolicitados = false;
|
|
final ServicioProgramacionAlarmas _programacion =
|
|
ServicioProgramacionAlarmas();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final alarma = widget.alarma;
|
|
final ahora = DateTime.now().add(const Duration(minutes: 5));
|
|
_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>[]};
|
|
_volumen = alarma?.volumen ?? 0.85;
|
|
_fadeInSegundos = (alarma?.fadeInSegundos ?? 0).clamp(0, 60).toInt();
|
|
_sonarEnVacaciones = alarma?.sonarEnVacaciones ?? true;
|
|
_sonidoInterno = alarma?.sonidoInterno ?? SonidoInternoAlarma.amanecer;
|
|
_snoozeMinutos = alarma?.snoozeMinutos ?? 5;
|
|
_emisora = alarma?.emisora ?? context.read<EstadoRadio>().emisoraPreferida;
|
|
_emisoraFallback = alarma?.emisoraFallback;
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
// Localizations cannot be read from initState (debug assert); the name
|
|
// controller is created lazily here on the first dependency pass.
|
|
if (_nombreController == null) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final alarma = widget.alarma;
|
|
_nombreController = TextEditingController(
|
|
text:
|
|
alarma == null
|
|
? l10n.defaultAlarmName
|
|
: _nombreVisibleAlarma(l10n, alarma),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nombreController?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final radio = context.watch<EstadoRadio>();
|
|
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
|
if (!_favoritosSolicitados) {
|
|
_favoritosSolicitados = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) context.read<EstadoRadio>().cargarFavoritos();
|
|
});
|
|
}
|
|
if (_emisora == null &&
|
|
widget.alarma == null &&
|
|
radio.emisoraPreferida != null) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && _emisora == null) {
|
|
setState(() => _emisora = radio.emisoraPreferida);
|
|
}
|
|
});
|
|
}
|
|
final favoritas = _favoritasConSeleccion(radio.listaFavoritos);
|
|
// Audit 8.1 (t4 lines 372-374): opaque, bottom-anchored, full-bleed
|
|
// sheet with rounded TOP corners only and a grab handle — was a
|
|
// floating card (glass, 12px margin on all 4 sides, uniform radius).
|
|
return DecoratedBox(
|
|
key: const ValueKey('alarm-editor-sheet-surface'),
|
|
decoration: const BoxDecoration(
|
|
color: Color(0xFF0D1B24),
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(30)),
|
|
border: Border(top: BorderSide(color: Color(0x1CFFFFFF))),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.fromLTRB(20, 14, 20, bottom + 24),
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
key: const ValueKey('alarm-editor-grab-handle'),
|
|
width: 44,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.22),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
_AssetIcon(
|
|
'assets/icons/alarmas/alarm_music.png',
|
|
size: 58,
|
|
semanticLabel: l10n.alarmIconLabel,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
widget.alarma == null
|
|
? l10n.newAlarmTitle
|
|
: l10n.editAlarmTitle,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close_rounded),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
TextField(
|
|
controller: _nombreController,
|
|
decoration: InputDecoration(labelText: l10n.nameLabel),
|
|
),
|
|
const SizedBox(height: 16),
|
|
// WU10: the native showTimePicker dialog is replaced by a
|
|
// giant inline HH:MM editor (drag/tap to adjust); see
|
|
// `EditorHoraInline`, standalone-tested on its own.
|
|
//
|
|
// Audit 8.3 (t4 line 378): framed in its own card — radius
|
|
// 22, `listSurface` (#102532), a thin border — instead of
|
|
// sitting bare on the sheet background. `EditorHoraInline`
|
|
// itself is unchanged (no other consumer to keep in sync).
|
|
DecoratedBox(
|
|
key: const ValueKey('alarm-editor-hour-card'),
|
|
decoration: BoxDecoration(
|
|
color: context.pluriTokens.listSurface,
|
|
borderRadius: BorderRadius.circular(22),
|
|
border: Border.all(
|
|
color: Colors.white.withValues(alpha: 0.08),
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
|
child: Center(
|
|
child: EditorHoraInline(
|
|
value: _hora,
|
|
onChanged: (nuevo) => setState(() => _hora = nuevo),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SegmentedButton<TipoProgramacionAlarma>(
|
|
segments: [
|
|
ButtonSegment(
|
|
value: TipoProgramacionAlarma.unica,
|
|
label: Text(l10n.oneTimeOption),
|
|
),
|
|
ButtonSegment(
|
|
value: TipoProgramacionAlarma.diaria,
|
|
label: Text(l10n.dailyOption),
|
|
),
|
|
ButtonSegment(
|
|
value: TipoProgramacionAlarma.diasSemana,
|
|
label: Text(l10n.weekdaysOption),
|
|
),
|
|
],
|
|
selected: {_tipo},
|
|
onSelectionChanged:
|
|
(value) => setState(() => _tipo = value.first),
|
|
),
|
|
const SizedBox(height: 10),
|
|
// Audit 8.5 (t4 line 381): the "REPETIR" eyebrow above the
|
|
// weekday circles -- never rendered anywhere before.
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
|
|
child: Text(
|
|
l10n.alarmRepeatSectionLabel,
|
|
style: context.pluriType.eyebrowLabel,
|
|
),
|
|
),
|
|
// WU10: weekday circles are now ALWAYS visible (previously
|
|
// only inserted into the tree in diasSemana mode) — matching
|
|
// the mockup, which shows them unconditionally under the
|
|
// giant time. They stay disabled (habilitado: false, the
|
|
// same "greyed out, non-interactive" state the previous
|
|
// FilterChip used) outside diasSemana mode rather than
|
|
// being wired to silently mutate `_diasSemana` while a
|
|
// different `_tipo` is saved — no scheduling-data-model
|
|
// change, presentation only.
|
|
//
|
|
// Audit 8.4 (t4 lines 383-390): 7 circles (flex:1 each,
|
|
// aspect-ratio:1, gap 7) — was a `Wrap` of `FilterChip`s.
|
|
Row(
|
|
key: const ValueKey('alarm-weekday-circles'),
|
|
children: [
|
|
for (
|
|
var i = DateTime.monday;
|
|
i <= DateTime.sunday;
|
|
i++
|
|
) ...[
|
|
if (i > DateTime.monday) const SizedBox(width: 7),
|
|
Expanded(
|
|
child: _CirculoDiaSemana(
|
|
label: _weekdayShort(l10n, i),
|
|
seleccionado: _diasSemana.contains(i),
|
|
habilitado:
|
|
_tipo == TipoProgramacionAlarma.diasSemana,
|
|
onTap:
|
|
() => setState(() {
|
|
_diasSemana.contains(i)
|
|
? _diasSemana.remove(i)
|
|
: _diasSemana.add(i);
|
|
}),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
_vistaProximaEjecucion(l10n),
|
|
const SizedBox(height: 14),
|
|
// Audit 8.6 (t4 lines 392-401): the station picker, volume,
|
|
// fade-in and vacation toggle now share ONE bordered card
|
|
// with sangred divider lines between rows -- matching the
|
|
// prototype's own single grouped block -- instead of
|
|
// sitting bare on the sheet, each with its own gaps. The
|
|
// extra fields the prototype does NOT show (name, type
|
|
// selector, snooze selector, "use current station",
|
|
// Advanced) stay OUTSIDE this card, exactly where they
|
|
// were (documented as deliberate in audit 8.8 / WU10).
|
|
DecoratedBox(
|
|
key: const ValueKey('alarm-editor-grouped-card'),
|
|
decoration: BoxDecoration(
|
|
color: context.pluriTokens.listSurface,
|
|
borderRadius: BorderRadius.circular(18),
|
|
border: Border.all(
|
|
color: Colors.white.withValues(alpha: 0.08),
|
|
),
|
|
),
|
|
// A transparent Material sits directly inside the coloured
|
|
// box, closer to the fade-in ListTile and the vacation
|
|
// SwitchListTile than the DecoratedBox's own opaque fill
|
|
// -- both paint their ink/background on the NEAREST
|
|
// Material ancestor, and without this the box's colour
|
|
// would hide those effects (Flutter's own debug-mode
|
|
// check for exactly this).
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: Column(
|
|
children: [
|
|
// S2-R9: searchable bottom-sheet picker instead of a
|
|
// dropdown, for the primary station. The backup
|
|
// (fallback) picker moves into the Advanced section
|
|
// below (WU10) — the primary choice stays a
|
|
// top-level field, only its secondary/backup sibling
|
|
// is now one tap further away.
|
|
_CampoSelectorEmisora(
|
|
key: const ValueKey('alarm-station-field'),
|
|
label: l10n.favoriteStationLabel,
|
|
icon: Icons.radio_rounded,
|
|
value:
|
|
_emisora == null
|
|
? l10n.noStationUseInternalSound
|
|
: localizedStationName(
|
|
l10n,
|
|
_emisora!.nombre,
|
|
),
|
|
onTap:
|
|
() => _elegirEmisora(
|
|
favoritas,
|
|
seleccionar:
|
|
(emisora) =>
|
|
setState(() => _emisora = emisora),
|
|
),
|
|
),
|
|
const Divider(height: 1, indent: 66),
|
|
// Audit 8.7 (t4 line 396): a compact 112px track with
|
|
// a trailing percentage label -- was a bare
|
|
// full-width Slider with no visible value.
|
|
_FilaVolumen(
|
|
volumen: _volumen,
|
|
onChanged:
|
|
(value) => setState(() => _volumen = value),
|
|
),
|
|
const Divider(height: 1, indent: 46),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 4, 14, 4),
|
|
child: ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(
|
|
l10n.alarmFadeInTitle,
|
|
style: context.pluriType.cardTitle,
|
|
),
|
|
subtitle: Text(
|
|
_fadeInSegundos == 0
|
|
? l10n.alarmFadeInOff
|
|
: l10n.alarmFadeInSummary(_fadeInSegundos),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
child: Slider(
|
|
value: _fadeInSegundos.toDouble(),
|
|
min: 0,
|
|
max: 60,
|
|
divisions: 60,
|
|
label: '${_fadeInSegundos}s',
|
|
onChanged:
|
|
(value) => setState(
|
|
() => _fadeInSegundos = value.round(),
|
|
),
|
|
),
|
|
),
|
|
const Divider(height: 1, indent: 46),
|
|
SwitchListTile.adaptive(
|
|
value: _sonarEnVacaciones,
|
|
onChanged:
|
|
(value) =>
|
|
setState(() => _sonarEnVacaciones = value),
|
|
secondary: _AssetIcon(
|
|
'assets/icons/alarmas/vacation_wave.png',
|
|
size: 42,
|
|
semanticLabel: l10n.vacationIconLabel,
|
|
),
|
|
title: Text(l10n.playDuringVacations),
|
|
subtitle: Text(l10n.playDuringVacationsHint),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (favoritas.isEmpty) ...[
|
|
const SizedBox(height: 6),
|
|
Text(l10n.saveFavoritesAlarmHint),
|
|
],
|
|
if (radio.emisoraActual != null) ...[
|
|
const SizedBox(height: 8),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: FilledButton.tonalIcon(
|
|
onPressed:
|
|
() => setState(() => _emisora = radio.emisoraActual),
|
|
icon: const Icon(Icons.add_task_rounded),
|
|
label: Text(l10n.useCurrentStationAction),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(l10n.alarmSnoozeDurationTitle),
|
|
subtitle: Text(l10n.alarmSnoozeOptionLabel(_snoozeMinutos)),
|
|
),
|
|
SegmentedButton<int>(
|
|
segments: [
|
|
for (final minutos in _opcionesSnooze())
|
|
ButtonSegment(
|
|
value: minutos,
|
|
label: Text(l10n.alarmSnoozeOptionLabel(minutos)),
|
|
),
|
|
],
|
|
selected: {_snoozeMinutos},
|
|
onSelectionChanged:
|
|
(value) => setState(() => _snoozeMinutos = value.first),
|
|
),
|
|
const SizedBox(height: 8),
|
|
// WU10 (native-alarms delta — Alarm Editor Preserves Date,
|
|
// Fallback Station, and Sound Fields): the mockup's editor
|
|
// shows only the giant time + weekday circles, but the
|
|
// one-time date field, the fallback-station picker, and the
|
|
// sound dropdown are NOT dropped — they move here, one tap
|
|
// away, instead of being always inline.
|
|
ExpansionTile(
|
|
key: const ValueKey('alarm-advanced-section'),
|
|
tilePadding: EdgeInsets.zero,
|
|
title: Text(
|
|
l10n.alarmAdvancedSectionTitle,
|
|
style: context.pluriType.cardTitle,
|
|
),
|
|
children: [
|
|
_PickerButton(
|
|
icon: Icons.event_rounded,
|
|
label: l10n.dateField,
|
|
value: _fechaCorta(l10n, _fecha),
|
|
onTap:
|
|
_tipo == TipoProgramacionAlarma.unica
|
|
? _elegirFecha
|
|
: null,
|
|
),
|
|
const SizedBox(height: 8),
|
|
_CampoSelectorEmisora(
|
|
key: const ValueKey('alarm-fallback-station-field'),
|
|
label: l10n.alarmFallbackStationLabel,
|
|
icon: Icons.settings_backup_restore_rounded,
|
|
value:
|
|
_emisoraFallback == null
|
|
? l10n.noStationUseInternalSound
|
|
: localizedStationName(
|
|
l10n,
|
|
_emisoraFallback!.nombre,
|
|
),
|
|
onTap:
|
|
() => _elegirEmisora(
|
|
favoritas,
|
|
seleccionar:
|
|
(emisora) =>
|
|
setState(() => _emisoraFallback = emisora),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
DropdownButtonFormField<SonidoInternoAlarma>(
|
|
initialValue: _sonidoInterno,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.internalSafeSoundLabel,
|
|
),
|
|
items: [
|
|
DropdownMenuItem(
|
|
value: SonidoInternoAlarma.amanecer,
|
|
child: Text(l10n.soundWarmSunrise),
|
|
),
|
|
DropdownMenuItem(
|
|
value: SonidoInternoAlarma.campanaSuave,
|
|
child: Text(l10n.soundSoftBell),
|
|
),
|
|
DropdownMenuItem(
|
|
value: SonidoInternoAlarma.pulsoDigital,
|
|
child: Text(l10n.soundDigitalPulse),
|
|
),
|
|
],
|
|
onChanged:
|
|
(value) => setState(
|
|
() => _sonidoInterno = value ?? _sonidoInterno,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
FilledButton.icon(
|
|
onPressed: _guardar,
|
|
icon: const Icon(Icons.check_rounded),
|
|
label: Text(l10n.saveAlarmAction),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<int> _opcionesSnooze() {
|
|
final opciones = <int>{3, 5, 10};
|
|
if (_snoozeMinutos > 0) opciones.add(_snoozeMinutos);
|
|
return opciones.toList()..sort();
|
|
}
|
|
|
|
/// Read-only next-trigger preview (S2-R8): computed from the in-progress
|
|
/// draft so the user can verify when the alarm will fire before saving.
|
|
/// Recomputed on every setState, so it tracks time/recurrence edits live.
|
|
Widget _vistaProximaEjecucion(AppLocalizations l10n) {
|
|
final estado = context.read<EstadoAlarmas>();
|
|
final borrador = AlarmaMusical(
|
|
id: widget.alarma?.id ?? '_borrador_editor',
|
|
nombre: 'preview',
|
|
hora: _hora.hour,
|
|
minuto: _hora.minute,
|
|
tipoProgramacion: _tipo,
|
|
diasSemana:
|
|
_tipo == TipoProgramacionAlarma.diasSemana
|
|
? (_diasSemana.toList()..sort())
|
|
: const [],
|
|
fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
|
|
sonarEnVacaciones: _sonarEnVacaciones,
|
|
);
|
|
final proxima = _programacion.calcularProxima(
|
|
alarma: borrador,
|
|
desde: DateTime.now(),
|
|
vacaciones: estado.vacaciones,
|
|
excepciones: estado.excepciones,
|
|
);
|
|
return _NoticeLine(
|
|
key: const ValueKey('next-trigger-preview'),
|
|
icon: Icons.event_available_rounded,
|
|
text:
|
|
proxima == null
|
|
? l10n.alarmNoNextExecution
|
|
: l10n.alarmNextExecution(_fechaHora(l10n, proxima)),
|
|
);
|
|
}
|
|
|
|
Future<void> _elegirEmisora(
|
|
List<Emisora> emisoras, {
|
|
required ValueChanged<Emisora?> seleccionar,
|
|
}) async {
|
|
final resultado = await showModalBottomSheet<_SeleccionEmisora>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) => _SelectorEmisoraSheet(emisoras: emisoras),
|
|
);
|
|
if (resultado == null) return;
|
|
seleccionar(resultado.emisora);
|
|
}
|
|
|
|
Future<void> _elegirFecha() async {
|
|
final ahora = DateTime.now();
|
|
final nueva = await showDatePicker(
|
|
context: context,
|
|
initialDate: _fecha.isBefore(ahora) ? ahora : _fecha,
|
|
firstDate: DateTime(ahora.year, ahora.month, ahora.day),
|
|
lastDate: ahora.add(const Duration(days: 730)),
|
|
);
|
|
if (nueva != null) setState(() => _fecha = nueva);
|
|
}
|
|
|
|
Future<void> _guardar() async {
|
|
if (_tipo == TipoProgramacionAlarma.diasSemana && _diasSemana.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(AppLocalizations.of(context).chooseOneWeekdayError),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final estado = context.read<EstadoAlarmas>();
|
|
final existente = widget.alarma;
|
|
final nombre = _nombreController?.text.trim() ?? '';
|
|
final alarma = (existente ??
|
|
estado.servicio.crearAlarma(
|
|
nombre: nombre,
|
|
hora: _hora.hour,
|
|
minuto: _hora.minute,
|
|
tipoProgramacion: _tipo,
|
|
diasSemana: _diasSemana.toList()..sort(),
|
|
))
|
|
.copyWith(
|
|
nombre:
|
|
nombre.isEmpty
|
|
? AppLocalizations.of(context).defaultAlarmName
|
|
: nombre,
|
|
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,
|
|
limpiarEmisora: _emisora == null,
|
|
emisoraFallback: _emisoraFallback,
|
|
limpiarEmisoraFallback: _emisoraFallback == null,
|
|
sonarEnVacaciones: _sonarEnVacaciones,
|
|
snoozeMinutos: _snoozeMinutos,
|
|
volumen: _volumen,
|
|
fadeInSegundos: _fadeInSegundos.clamp(0, 60).toInt(),
|
|
sonidoInterno: _sonidoInterno,
|
|
activa: true,
|
|
);
|
|
// The cap-check + interstitial already ran in `PantallaAlarmas
|
|
// ._abrirEditor` BEFORE this sheet ever opened (ADR-6 ordering: "then
|
|
// open the editor"). This is only the defense-in-depth backstop against
|
|
// the state-layer choke point — e.g. a 2nd device created alarms while
|
|
// this sheet was open — the true authority is `guardarAlarma` itself.
|
|
final resultado = await estado.guardarAlarma(alarma);
|
|
if (!mounted) return;
|
|
if (resultado == ResultadoGuardarAlarma.limiteAlcanzado) {
|
|
_mostrarLimiteAlarmas(context);
|
|
return;
|
|
}
|
|
Navigator.pop(context);
|
|
}
|
|
|
|
/// Freemium-gating spec "Alarm Cap UX Never Bare-Jumps To Paywall": an
|
|
/// explanatory message with a SECONDARY unlock action — never a direct
|
|
/// paywall navigation as the sole response to hitting the cap.
|
|
void _mostrarLimiteAlarmas(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(l10n.limiteAlarmasAlcanzado),
|
|
action: SnackBarAction(
|
|
label: l10n.desbloquearPremium,
|
|
onPressed: () => mostrarHojaPremium(context),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Emisora> _favoritasConSeleccion(List<Emisora> favoritas) {
|
|
final mapa = <String, Emisora>{};
|
|
for (final emisora in favoritas) {
|
|
mapa[emisora.uuid] = emisora;
|
|
}
|
|
final seleccionada = _emisora;
|
|
if (seleccionada != null) {
|
|
mapa[seleccionada.uuid] = seleccionada;
|
|
}
|
|
final respaldo = _emisoraFallback;
|
|
if (respaldo != null) {
|
|
mapa[respaldo.uuid] = respaldo;
|
|
}
|
|
return mapa.values.toList();
|
|
}
|
|
}
|
|
|
|
/// Result wrapper so the picker can distinguish "cancelled" (null result)
|
|
/// from "no station chosen" (emisora == null).
|
|
class _SeleccionEmisora {
|
|
const _SeleccionEmisora(this.emisora);
|
|
|
|
final Emisora? emisora;
|
|
}
|
|
|
|
class _CampoSelectorEmisora extends StatelessWidget {
|
|
const _CampoSelectorEmisora({
|
|
super.key,
|
|
required this.label,
|
|
required this.icon,
|
|
required this.value,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String label;
|
|
final IconData icon;
|
|
final String value;
|
|
final VoidCallback onTap;
|
|
|
|
/// Audit 8.6 (t4:394): a flat nav row -- icon, bold label, muted value,
|
|
/// chevron -- matching the same convention `FilaAjuste` uses everywhere
|
|
/// else in Settings, so this fits cleanly inside the grouped card below
|
|
/// instead of drawing its own outlined `InputDecoration` chrome.
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final type = context.pluriType;
|
|
return Material(
|
|
type: MaterialType.transparency,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 20),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: type.cardTitle),
|
|
Text(
|
|
value,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: type.bodyStrong.copyWith(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurface.withValues(alpha: 0.55),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(
|
|
Icons.chevron_right_rounded,
|
|
size: 19,
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurface.withValues(alpha: 0.4),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Audit 8.7 (t4 line 396): a compact volume row -- icon, label, a 112px
|
|
/// track, and a trailing "80%" -- was a bare full-width `Slider` with no
|
|
/// visible current value. Constrains a real, still-draggable `Slider`
|
|
/// (not a static bar) to the prototype's 112px track width via a
|
|
/// `SliderTheme` + fixed-width `SizedBox`, preserving drag interactivity.
|
|
class _FilaVolumen extends StatelessWidget {
|
|
const _FilaVolumen({required this.volumen, required this.onChanged});
|
|
|
|
final double volumen;
|
|
final ValueChanged<double> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final onSurface = Theme.of(context).colorScheme.onSurface;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.volume_up_rounded,
|
|
size: 20,
|
|
color: onSurface.withValues(alpha: 0.7),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
l10n.alarmVolumeLabel,
|
|
style: context.pluriType.cardTitle,
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 112,
|
|
height: 24,
|
|
child: SliderTheme(
|
|
data: SliderTheme.of(context).copyWith(
|
|
trackHeight: 4,
|
|
activeTrackColor: PluriWaveTokens.brand,
|
|
inactiveTrackColor: Colors.white.withValues(alpha: 0.14),
|
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
|
|
),
|
|
child: Slider(
|
|
value: volumen,
|
|
// S2-R11: floor lowered from 0.25 to 0.0.
|
|
min: 0,
|
|
max: 1,
|
|
divisions: 20,
|
|
label: '${(volumen * 100).round()}%',
|
|
onChanged: onChanged,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 34,
|
|
child: Text(
|
|
'${(volumen * 100).round()}%',
|
|
textAlign: TextAlign.right,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w800,
|
|
color: onSurface.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Searchable station picker (S2-R9): bottom sheet with a [SearchBar] over
|
|
/// the user's favorites, matching the main station-picker interaction.
|
|
class _SelectorEmisoraSheet extends StatefulWidget {
|
|
const _SelectorEmisoraSheet({required this.emisoras});
|
|
|
|
final List<Emisora> emisoras;
|
|
|
|
@override
|
|
State<_SelectorEmisoraSheet> createState() => _SelectorEmisoraSheetState();
|
|
}
|
|
|
|
class _SelectorEmisoraSheetState extends State<_SelectorEmisoraSheet> {
|
|
String _filtro = '';
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
|
final query = _filtro.trim().toLowerCase();
|
|
final filtradas =
|
|
widget.emisoras.where((emisora) {
|
|
if (query.isEmpty) return true;
|
|
return localizedStationName(
|
|
l10n,
|
|
emisora.nombre,
|
|
).toLowerCase().contains(query) ||
|
|
emisora.nombre.toLowerCase().contains(query);
|
|
}).toList();
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12),
|
|
child: PluriGlassSurface(
|
|
borderRadius: BorderRadius.circular(28),
|
|
padding: const EdgeInsets.all(18),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(context).size.height * 0.7,
|
|
),
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SearchBar(
|
|
hintText: l10n.alarmStationPickerSearchHint,
|
|
leading: const Icon(Icons.search_rounded),
|
|
onChanged: (value) => setState(() => _filtro = value),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Flexible(
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.music_off_rounded),
|
|
title: Text(l10n.noStationUseInternalSound),
|
|
onTap:
|
|
() => Navigator.pop(
|
|
context,
|
|
const _SeleccionEmisora(null),
|
|
),
|
|
),
|
|
for (final emisora in filtradas)
|
|
ListTile(
|
|
leading: const Icon(Icons.radio_rounded),
|
|
title: Text(
|
|
localizedStationName(l10n, emisora.nombre),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
onTap:
|
|
() => Navigator.pop(
|
|
context,
|
|
_SeleccionEmisora(emisora),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Entry point into the full Android alarm-reliability diagnostics screen
|
|
/// (fix/alarmas-fiabilidad). Was a one-line `TextButton.icon` that only ever
|
|
/// surfaced 3 of the 6 fields `DiagnosticoAlarmasAndroid` collects (exact
|
|
/// alarms, notifications, full-screen intent) and cycled all three
|
|
/// permission requests on a single tap; the two most diagnostic fields --
|
|
/// battery-optimization exemption and the native pending-alarm count, which
|
|
/// tells the user whether the alarm ever reached the OS at all -- were
|
|
/// gathered and never shown. Now a tap target row (mirrors
|
|
/// `_PanelVacaciones`'s shape) pushing `PantallaDiagnosticoAlarmas`, which
|
|
/// shows every signal individually with its own fix action.
|
|
class _AccesoDiagnostico extends StatelessWidget {
|
|
const _AccesoDiagnostico({required this.estado});
|
|
|
|
final EstadoAlarmas estado;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final tokens = context.pluriTokens;
|
|
return PluriGlassSurface(
|
|
padding: EdgeInsets.zero,
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: InkWell(
|
|
key: const ValueKey('diagnostico-alarmas-resumen'),
|
|
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
|
onTap: () => _abrirDiagnostico(context),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
const _AssetIcon(
|
|
'assets/icons/alarmas/android_reliability.png',
|
|
size: 28,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(child: Text(l10n.androidReliabilityReview)),
|
|
const Icon(Icons.chevron_right_rounded),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _abrirDiagnostico(BuildContext context) {
|
|
PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas());
|
|
}
|
|
}
|
|
|
|
/// Vacation summary row (alarm-vacation-ranges delta, WU8): replaces the old
|
|
/// always-inline range list with a tap target showing range count + next-
|
|
/// range countdown, pushing the Vacaciones manager screen. WU9 owns the
|
|
/// destination screen's real content (`EstadoAlarmas` query additions per
|
|
/// design ADR-6); this row's tap target is a temporary placeholder until then
|
|
/// (see `_PantallaVacacionesTemporal` below).
|
|
class _PanelVacaciones extends StatelessWidget {
|
|
const _PanelVacaciones({required this.estado});
|
|
|
|
final EstadoAlarmas estado;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final tokens = context.pluriTokens;
|
|
final resumen = _resumenVacaciones(l10n, estado.vacaciones);
|
|
// Audit 7.3 (t4:332): a trailing date-range pill ("4-18 AGO") for the
|
|
// active-or-next range -- never rendered anywhere before.
|
|
final proximas = estado.vacacionesProximas();
|
|
final rangoRelevante =
|
|
estado.rangoVacacionesActivo() ??
|
|
(proximas.isEmpty ? null : proximas.first);
|
|
return PluriGlassSurface(
|
|
glowColor: PluriWaveTokens.skyBlue.withValues(alpha: 0.22),
|
|
padding: EdgeInsets.zero,
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: InkWell(
|
|
key: const ValueKey('vacaciones-resumen'),
|
|
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
|
onTap: () => _abrirVacaciones(context),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
_AssetIcon(
|
|
'assets/icons/alarmas/vacation_wave.png',
|
|
size: 48,
|
|
semanticLabel: l10n.vacationIconLabel,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.vacationRangesTitle,
|
|
style: Theme.of(context).textTheme.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.w900),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(resumen),
|
|
],
|
|
),
|
|
),
|
|
if (rangoRelevante != null) ...[
|
|
const SizedBox(width: 8),
|
|
_PildoraFechasVacaciones(rango: rangoRelevante),
|
|
],
|
|
const Icon(Icons.chevron_right_rounded),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _abrirVacaciones(BuildContext context) {
|
|
PluriPushScaffold.push(context, (_) => const PantallaVacaciones());
|
|
}
|
|
|
|
/// Range count + next-range countdown, computed directly over the existing
|
|
/// `estado.vacaciones` (no new `EstadoAlarmas` query method — those are
|
|
/// design ADR-6's pure additions, owned by WU9).
|
|
String _resumenVacaciones(
|
|
AppLocalizations l10n,
|
|
List<RangoVacaciones> vacaciones,
|
|
) {
|
|
if (vacaciones.isEmpty) return l10n.noVacationRangesLoaded;
|
|
final ahora = DateTime.now();
|
|
final hoy = DateTime(ahora.year, ahora.month, ahora.day);
|
|
final conteo = l10n.vacationRangesCount(vacaciones.length);
|
|
RangoVacaciones? activo;
|
|
for (final rango in vacaciones) {
|
|
if (rango.contiene(ahora)) {
|
|
activo = rango;
|
|
break;
|
|
}
|
|
}
|
|
if (activo != null) {
|
|
final dias = activo.finDia.difference(hoy).inDays;
|
|
return '$conteo · ${l10n.vacationSummaryActiveCountdown(dias)}';
|
|
}
|
|
final futuros =
|
|
vacaciones.where((rango) => rango.inicioDia.isAfter(hoy)).toList()
|
|
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
|
|
if (futuros.isNotEmpty) {
|
|
final dias = futuros.first.inicioDia.difference(hoy).inDays;
|
|
return '$conteo · ${l10n.vacationSummaryUpcomingCountdown(dias)}';
|
|
}
|
|
return conteo;
|
|
}
|
|
}
|
|
|
|
/// Audit 7.3 (t4:332): "4-18 AGO" -- `radius:999`, `liveGreen@.16` fill,
|
|
/// `liveGreen@.38` border, 10.5px/w800 in `liveGreen` (matches the
|
|
/// prototype's own `rgba(126,228,194,...)` teal, not the alarm banner's
|
|
/// warmCoral).
|
|
class _PildoraFechasVacaciones extends StatelessWidget {
|
|
const _PildoraFechasVacaciones({required this.rango});
|
|
|
|
final RangoVacaciones rango;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = context.pluriTokens;
|
|
final locale = AppLocalizations.of(context).localeName;
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: tokens.liveGreen.withValues(alpha: 0.16),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.38)),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
|
child: Text(
|
|
rangoFechasCorto(locale, rango.inicioDia, rango.finDia),
|
|
style: TextStyle(
|
|
fontSize: 10.5,
|
|
fontWeight: FontWeight.w800,
|
|
color: tokens.liveGreen,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Audit 8.4 (t4 lines 383-390): one circular weekday button in the alarm
|
|
/// editor's REPETIR row — was a `FilterChip`. `aspect-ratio:1` in the
|
|
/// prototype is achieved here by the caller wrapping each instance in an
|
|
/// `Expanded` inside an `AspectRatio`-free `Row` — this widget's own
|
|
/// `AspectRatio(1)` does the squaring regardless of the column width the
|
|
/// `Row` assigns it.
|
|
class _CirculoDiaSemana extends StatelessWidget {
|
|
const _CirculoDiaSemana({
|
|
required this.label,
|
|
required this.seleccionado,
|
|
required this.habilitado,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String label;
|
|
final bool seleccionado;
|
|
final bool habilitado;
|
|
final VoidCallback onTap;
|
|
|
|
/// t4 lines 384-388: the selected circle's TEXT is this specific dark
|
|
/// literal — this theme's `colorScheme.onPrimary` is `Colors.white`
|
|
/// (`pluriwave_theme.dart:23`), which would fail contrast against the
|
|
/// bright cyan fill. No existing token matches this exact value.
|
|
static const _textoSobreBrand = Color(0xFF062126);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = context.pluriTokens;
|
|
final colorFondo =
|
|
seleccionado ? PluriWaveTokens.brand : tokens.listSurface;
|
|
final colorTexto =
|
|
seleccionado
|
|
? _textoSobreBrand
|
|
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6);
|
|
|
|
return AspectRatio(
|
|
aspectRatio: 1,
|
|
child: Material(
|
|
color: colorFondo,
|
|
shape: CircleBorder(
|
|
side:
|
|
seleccionado
|
|
? BorderSide.none
|
|
: BorderSide(color: Colors.white.withValues(alpha: 0.1)),
|
|
),
|
|
child: InkWell(
|
|
customBorder: const CircleBorder(),
|
|
onTap: habilitado ? onTap : null,
|
|
child: Center(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: seleccionado ? FontWeight.w800 : FontWeight.w700,
|
|
color:
|
|
habilitado ? colorTexto : colorTexto.withValues(alpha: 0.4),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AssetIcon extends StatelessWidget {
|
|
const _AssetIcon(this.asset, {this.size = 44, this.semanticLabel});
|
|
|
|
final String asset;
|
|
final double size;
|
|
|
|
/// S5-R2: meaningful images carry a label; without one the image is
|
|
/// treated as decorative and excluded from the semantics tree.
|
|
final String? semanticLabel;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Image.asset(
|
|
asset,
|
|
width: size,
|
|
height: size,
|
|
fit: BoxFit.contain,
|
|
semanticLabel: semanticLabel,
|
|
excludeFromSemantics: semanticLabel == null,
|
|
errorBuilder:
|
|
(_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PickerButton extends StatelessWidget {
|
|
const _PickerButton({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.value,
|
|
required this.onTap,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final String value;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return OutlinedButton.icon(
|
|
onPressed: onTap,
|
|
icon: Icon(icon),
|
|
label: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: Theme.of(context).textTheme.labelSmall),
|
|
Text(value),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NoticeLine extends StatelessWidget {
|
|
const _NoticeLine({super.key, required this.icon, required this.text});
|
|
|
|
final IconData icon;
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(text)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptyAlarmas extends StatelessWidget {
|
|
const _EmptyAlarmas();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
children: [
|
|
_AssetIcon(
|
|
'assets/icons/alarmas/alarm_music.png',
|
|
size: 92,
|
|
semanticLabel: l10n.alarmIconLabel,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(l10n.noAlarmsYetTitle),
|
|
const SizedBox(height: 4),
|
|
Text(l10n.noAlarmsYetSubtitle),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _nombreVisibleAlarma(AppLocalizations l10n, AlarmaMusical alarma) {
|
|
return localizedAlarmName(l10n, alarma.nombre);
|
|
}
|
|
|
|
String _hora(AlarmaMusical alarma) =>
|
|
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
|
|
|
|
String _fechaHora(AppLocalizations l10n, DateTime fecha) =>
|
|
l10n.dateTimeSentence(fecha);
|
|
|
|
String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
|
|
DateTime.monday => l10n.weekdayShortMonday,
|
|
DateTime.tuesday => l10n.weekdayShortTuesday,
|
|
DateTime.wednesday => l10n.weekdayShortWednesday,
|
|
DateTime.thursday => l10n.weekdayShortThursday,
|
|
DateTime.friday => l10n.weekdayShortFriday,
|
|
DateTime.saturday => l10n.weekdayShortSaturday,
|
|
DateTime.sunday => l10n.weekdayShortSunday,
|
|
_ => '?',
|
|
};
|
|
|
|
// S5-R4: short dates follow the active locale (en-US = M/D/Y, ja = Y/M/D).
|
|
String _fechaCorta(AppLocalizations l10n, DateTime fecha) =>
|
|
fechaCortaLocalizada(l10n.localeName, fecha);
|
|
|
|
/// Audit 7.4 (t4:339) / item 5: a compact recurrence label next to the
|
|
/// alarm card's giant time. `diaria`/`unica` still show the SAME generic
|
|
/// labels the editor's own `TipoProgramacionAlarma` `SegmentedButton`
|
|
/// already uses (`dailyOption`/`oneTimeOption`) -- both are already fully
|
|
/// specific (there is nothing more concrete to say than "every day"/"just
|
|
/// once"). `diasSemana` now renders the alarm's ACTUAL configured days
|
|
/// (e.g. "Lun, Mié, Vie") instead of the generic `weekdaysOption` ("Días"),
|
|
/// reusing [_weekdayShort] (the SAME per-day abbreviation the editor's own
|
|
/// day-picker circles already use) -- no new ARB keys, no second
|
|
/// formatting scheme, and the resulting Text is wrapped in a
|
|
/// `Flexible`+ellipsis at the call site so a long selection never
|
|
/// overflows the row.
|
|
String _recurrenciaCorta(AppLocalizations l10n, AlarmaMusical alarma) {
|
|
return switch (alarma.tipoProgramacion) {
|
|
TipoProgramacionAlarma.diaria => l10n.dailyOption,
|
|
TipoProgramacionAlarma.diasSemana => _diasSemanaCorto(
|
|
l10n,
|
|
alarma.diasSemana,
|
|
),
|
|
TipoProgramacionAlarma.unica => l10n.oneTimeOption,
|
|
};
|
|
}
|
|
|
|
/// The real, ordered day abbreviations for a `diasSemana` alarm (item 5),
|
|
/// e.g. "Lun, Mié, Vie". [diasSemana] is re-sorted defensively (the editor
|
|
/// always persists it sorted, but this does not rely on that). Falls back
|
|
/// to the generic [AppLocalizations.weekdaysOption] label when
|
|
/// [diasSemana] is empty -- the editor already blocks saving an empty
|
|
/// selection in this mode, but a corrupt/legacy persisted record could
|
|
/// still reach here, and showing nothing would be worse than the old
|
|
/// generic label.
|
|
String _diasSemanaCorto(AppLocalizations l10n, List<int> diasSemana) {
|
|
if (diasSemana.isEmpty) return l10n.weekdaysOption;
|
|
final ordenados = [...diasSemana]..sort();
|
|
return ordenados.map((dia) => _weekdayShort(l10n, dia)).join(', ');
|
|
}
|