Compare commits
5
Commits
61d873f035
...
dc21732027
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc21732027 | ||
|
|
c9fe0ad651 | ||
|
|
e1732af222 | ||
|
|
a2121d84bd | ||
|
|
332c2192cd |
@@ -7,6 +7,7 @@ import '../modelos/preset_ecualizador.dart';
|
|||||||
import '../servicios/servicio_audio.dart';
|
import '../servicios/servicio_audio.dart';
|
||||||
import '../servicios/servicio_dispositivo_audio.dart';
|
import '../servicios/servicio_dispositivo_audio.dart';
|
||||||
import '../servicios/servicio_ecualizador.dart';
|
import '../servicios/servicio_ecualizador.dart';
|
||||||
|
import '../servicios/servicio_presets_personalizados.dart';
|
||||||
|
|
||||||
/// Equalizer state extracted from `EstadoRadio` (S4-R1).
|
/// Equalizer state extracted from `EstadoRadio` (S4-R1).
|
||||||
///
|
///
|
||||||
@@ -30,8 +31,11 @@ class EstadoEcualizador extends ChangeNotifier {
|
|||||||
required this.audio,
|
required this.audio,
|
||||||
ServicioEcualizador? servicio,
|
ServicioEcualizador? servicio,
|
||||||
ServicioDispositivoAudio? dispositivoAudio,
|
ServicioDispositivoAudio? dispositivoAudio,
|
||||||
|
ServicioPresetsPersonalizados? presetsPersonalizadosService,
|
||||||
String? Function()? emisoraActualUuid,
|
String? Function()? emisoraActualUuid,
|
||||||
}) : servicio = servicio ?? ServicioEcualizador(),
|
}) : servicio = servicio ?? ServicioEcualizador(),
|
||||||
|
_presetsPersonalizadosService =
|
||||||
|
presetsPersonalizadosService ?? ServicioPresetsPersonalizados(),
|
||||||
_dispositivoAudio = dispositivoAudio,
|
_dispositivoAudio = dispositivoAudio,
|
||||||
_emisoraActualUuid = emisoraActualUuid ?? (() => null);
|
_emisoraActualUuid = emisoraActualUuid ?? (() => null);
|
||||||
|
|
||||||
@@ -39,6 +43,12 @@ class EstadoEcualizador extends ChangeNotifier {
|
|||||||
final ServicioEcualizador servicio;
|
final ServicioEcualizador servicio;
|
||||||
final ServicioDispositivoAudio? _dispositivoAudio;
|
final ServicioDispositivoAudio? _dispositivoAudio;
|
||||||
|
|
||||||
|
/// Persistence for user-named custom presets (design ADR-5 hazard box).
|
||||||
|
/// Deliberately a SEPARATE service/key from [servicio] — see
|
||||||
|
/// [cargarPresetsPersonalizados] for why its load is not folded into
|
||||||
|
/// [cargarPersistido].
|
||||||
|
final ServicioPresetsPersonalizados _presetsPersonalizadosService;
|
||||||
|
|
||||||
/// Callback into the owner (EstadoRadio) for the currently playing station;
|
/// Callback into the owner (EstadoRadio) for the currently playing station;
|
||||||
/// keeps this notifier free of any station-list coupling.
|
/// keeps this notifier free of any station-list coupling.
|
||||||
final String? Function() _emisoraActualUuid;
|
final String? Function() _emisoraActualUuid;
|
||||||
@@ -61,6 +71,11 @@ class EstadoEcualizador extends ChangeNotifier {
|
|||||||
/// every session without needing a SharedPreferences key or migration.
|
/// every session without needing a SharedPreferences key or migration.
|
||||||
final Map<String, String> _nombresPlataforma = {};
|
final Map<String, String> _nombresPlataforma = {};
|
||||||
|
|
||||||
|
/// User-named custom presets (WU13). Loaded explicitly via
|
||||||
|
/// [cargarPresetsPersonalizados], not as part of [cargarPersistido] —
|
||||||
|
/// see that method's doc for why.
|
||||||
|
List<PresetEcualizador> _presetsPersonalizados = [];
|
||||||
|
|
||||||
PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
|
PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
|
||||||
PresetEcualizador _presetActual = PresetEcualizador.flat;
|
PresetEcualizador _presetActual = PresetEcualizador.flat;
|
||||||
bool _activo = true;
|
bool _activo = true;
|
||||||
@@ -88,6 +103,9 @@ class EstadoEcualizador extends ChangeNotifier {
|
|||||||
Map<String, PresetEcualizador> get presetsMatriz =>
|
Map<String, PresetEcualizador> get presetsMatriz =>
|
||||||
Map.unmodifiable(_presetsMatriz);
|
Map.unmodifiable(_presetsMatriz);
|
||||||
|
|
||||||
|
List<PresetEcualizador> get presetsPersonalizados =>
|
||||||
|
List.unmodifiable(_presetsPersonalizados);
|
||||||
|
|
||||||
bool get emisoraActualTienePresetPropio {
|
bool get emisoraActualTienePresetPropio {
|
||||||
final uuid = _emisoraActualUuid();
|
final uuid = _emisoraActualUuid();
|
||||||
if (uuid == null) return false;
|
if (uuid == null) return false;
|
||||||
@@ -424,6 +442,52 @@ class EstadoEcualizador extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loads the persisted custom-preset list (WU13, `eq-custom-presets`
|
||||||
|
/// spec — the Settings EQ screen's preset chip row).
|
||||||
|
///
|
||||||
|
/// Deliberately NOT part of [cargarPersistido]: that method is exercised
|
||||||
|
/// roughly 30 times by `estado_ecualizador_test.dart` — one of this
|
||||||
|
/// change's protected EQ test files, required to pass **unmodified** —
|
||||||
|
/// via Fakes for [servicio]/[_dispositivoAudio] only, with no
|
||||||
|
/// SharedPreferences awareness anywhere in that file. Folding a third,
|
||||||
|
/// always-real-by-default collaborator into [cargarPersistido] would
|
||||||
|
/// introduce a real SharedPreferences call into every one of those
|
||||||
|
/// cases. Called explicitly by the Settings EQ screen instead, the same
|
||||||
|
/// way `refrescarDispositivoActual` is already called from screen
|
||||||
|
/// `initState`, not from [cargarPersistido].
|
||||||
|
Future<void> cargarPresetsPersonalizados() async {
|
||||||
|
_presetsPersonalizados = await _presetsPersonalizadosService.listar();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the CURRENT effective preset's bands (`presetActual`) as a new
|
||||||
|
/// named custom preset (spec "Custom Preset Save").
|
||||||
|
///
|
||||||
|
/// Returns `false` — and persists nothing — when [nombre] is empty or
|
||||||
|
/// whitespace-only (spec "Custom Preset Naming Validates Non-Empty
|
||||||
|
/// Input"), the same non-crashing validate-before-persist shape
|
||||||
|
/// [renombrarDispositivo] already uses elsewhere in this class.
|
||||||
|
Future<bool> guardarPresetPersonalizado(String nombre) async {
|
||||||
|
final nombreValido = nombre.trim();
|
||||||
|
if (nombreValido.isEmpty) return false;
|
||||||
|
|
||||||
|
final preset = PresetEcualizador(
|
||||||
|
nombre: nombreValido,
|
||||||
|
bandas: List<double>.from(_presetActual.bandas),
|
||||||
|
);
|
||||||
|
await _presetsPersonalizadosService.guardar(preset);
|
||||||
|
_presetsPersonalizados = await _presetsPersonalizadosService.listar();
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the custom preset named [nombre], if present.
|
||||||
|
Future<void> eliminarPresetPersonalizado(String nombre) async {
|
||||||
|
await _presetsPersonalizadosService.eliminar(nombre);
|
||||||
|
_presetsPersonalizados = await _presetsPersonalizadosService.listar();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Persists a custom display name for [deviceId].
|
/// Persists a custom display name for [deviceId].
|
||||||
///
|
///
|
||||||
/// Empty names are silently ignored so the existing name is preserved.
|
/// Empty names are silently ignored so the existing name is preserved.
|
||||||
|
|||||||
@@ -95,6 +95,17 @@
|
|||||||
"equalizerPerStationTitle": "Use custom EQ for this favorite",
|
"equalizerPerStationTitle": "Use custom EQ for this favorite",
|
||||||
"equalizerPerStationActive": "Active for {stationName}",
|
"equalizerPerStationActive": "Active for {stationName}",
|
||||||
"equalizerPerStationMain": "Using main EQ for {stationName}",
|
"equalizerPerStationMain": "Using main EQ for {stationName}",
|
||||||
|
"equalizerBaseExplainer": "This is the base equalizer: it applies to every station without its own setting. A station's own EQ is set from its own playback screen and overrides this one.",
|
||||||
|
"equalizerActiveOutputLabel": "Active output",
|
||||||
|
"equalizerActiveOutputDefault": "This device's speaker",
|
||||||
|
"equalizerStationsWithOwnEqTitle": "Stations with their own EQ",
|
||||||
|
"equalizerStationsWithOwnEqSubtitle": "Ignore this base equalizer",
|
||||||
|
"equalizerStationsWithOwnEqEmpty": "No stations have their own EQ yet.",
|
||||||
|
"equalizerSaveAsPresetAction": "Save as preset",
|
||||||
|
"equalizerSavePresetDialogTitle": "Save as preset",
|
||||||
|
"equalizerSavePresetNameLabel": "Preset name",
|
||||||
|
"equalizerSavePresetEmptyNameError": "Enter a name for the preset.",
|
||||||
|
"equalizerSavePresetConfirm": "Save",
|
||||||
"preferredStationTitle": "Preferred station",
|
"preferredStationTitle": "Preferred station",
|
||||||
"preferredStationDescription": "Preselected for new alarms and available for quick playback.",
|
"preferredStationDescription": "Preselected for new alarms and available for quick playback.",
|
||||||
"preferredStationNoStationsTitle": "No stations available yet",
|
"preferredStationNoStationsTitle": "No stations available yet",
|
||||||
@@ -690,6 +701,8 @@
|
|||||||
"notPlaying": "Not playing",
|
"notPlaying": "Not playing",
|
||||||
"oneTimeOption": "Once",
|
"oneTimeOption": "Once",
|
||||||
"pausePlaybackTooltip": "Pause playback",
|
"pausePlaybackTooltip": "Pause playback",
|
||||||
|
"playerQualityChangeAction": "Change",
|
||||||
|
"playerToolEqLabel": "Own EQ",
|
||||||
"qualityOriginal": "Original quality: {quality}",
|
"qualityOriginal": "Original quality: {quality}",
|
||||||
"@qualityOriginal": {
|
"@qualityOriginal": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -720,6 +733,10 @@
|
|||||||
"stationCount": "{count, plural, =1{1 station} other{{count} stations}}",
|
"stationCount": "{count, plural, =1{1 station} other{{count} stations}}",
|
||||||
"alarmIconLabel": "Musical alarm",
|
"alarmIconLabel": "Musical alarm",
|
||||||
"vacationIconLabel": "Vacation mode",
|
"vacationIconLabel": "Vacation mode",
|
||||||
|
"alarmAdvancedSectionTitle": "Advanced",
|
||||||
|
"alarmInlineHourLabel": "Hour",
|
||||||
|
"alarmInlineMinuteLabel": "Minute",
|
||||||
|
"alarmVolumeRisingStatus": "Turning up the volume",
|
||||||
"streamUrlHint": "https://stream.example.com:8000/radio",
|
"streamUrlHint": "https://stream.example.com:8000/radio",
|
||||||
"advancedEqSectionTitle": "Advanced Equalization Options",
|
"advancedEqSectionTitle": "Advanced Equalization Options",
|
||||||
"advancedEqEnableToggle": "Enable per-device EQ",
|
"advancedEqEnableToggle": "Enable per-device EQ",
|
||||||
|
|||||||
@@ -95,6 +95,17 @@
|
|||||||
"equalizerPerStationTitle": "Usar EQ propio para esta favorita",
|
"equalizerPerStationTitle": "Usar EQ propio para esta favorita",
|
||||||
"equalizerPerStationActive": "Activo para {stationName}",
|
"equalizerPerStationActive": "Activo para {stationName}",
|
||||||
"equalizerPerStationMain": "Usando EQ principal para {stationName}",
|
"equalizerPerStationMain": "Usando EQ principal para {stationName}",
|
||||||
|
"equalizerBaseExplainer": "Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.",
|
||||||
|
"equalizerActiveOutputLabel": "Salida activa",
|
||||||
|
"equalizerActiveOutputDefault": "El altavoz de este dispositivo",
|
||||||
|
"equalizerStationsWithOwnEqTitle": "Emisoras con ajuste propio",
|
||||||
|
"equalizerStationsWithOwnEqSubtitle": "Ignoran este ecualizador base",
|
||||||
|
"equalizerStationsWithOwnEqEmpty": "Ninguna emisora tiene ajuste propio todavía.",
|
||||||
|
"equalizerSaveAsPresetAction": "Guardar como preset",
|
||||||
|
"equalizerSavePresetDialogTitle": "Guardar como preset",
|
||||||
|
"equalizerSavePresetNameLabel": "Nombre del preset",
|
||||||
|
"equalizerSavePresetEmptyNameError": "Ingresá un nombre para el preset.",
|
||||||
|
"equalizerSavePresetConfirm": "Guardar",
|
||||||
"preferredStationTitle": "Emisora preferida",
|
"preferredStationTitle": "Emisora preferida",
|
||||||
"preferredStationDescription": "Se preselecciona al crear alarmas y puede iniciarse como reproducción rápida.",
|
"preferredStationDescription": "Se preselecciona al crear alarmas y puede iniciarse como reproducción rápida.",
|
||||||
"preferredStationNoStationsTitle": "Todavía no hay emisoras disponibles",
|
"preferredStationNoStationsTitle": "Todavía no hay emisoras disponibles",
|
||||||
@@ -646,6 +657,8 @@
|
|||||||
"notPlaying": "No está reproduciendo",
|
"notPlaying": "No está reproduciendo",
|
||||||
"oneTimeOption": "Una vez",
|
"oneTimeOption": "Una vez",
|
||||||
"pausePlaybackTooltip": "Pausar reproducción",
|
"pausePlaybackTooltip": "Pausar reproducción",
|
||||||
|
"playerQualityChangeAction": "Cambiar",
|
||||||
|
"playerToolEqLabel": "EQ propio",
|
||||||
"qualityOriginal": "Calidad original: {quality}",
|
"qualityOriginal": "Calidad original: {quality}",
|
||||||
"@qualityOriginal": {"placeholders": {"quality": {}}},
|
"@qualityOriginal": {"placeholders": {"quality": {}}},
|
||||||
"qualityUnknown": "Calidad no informada",
|
"qualityUnknown": "Calidad no informada",
|
||||||
@@ -672,6 +685,10 @@
|
|||||||
"stationCount": "{count, plural, =1{1 emisora} other{{count} emisoras}}",
|
"stationCount": "{count, plural, =1{1 emisora} other{{count} emisoras}}",
|
||||||
"alarmIconLabel": "Alarma musical",
|
"alarmIconLabel": "Alarma musical",
|
||||||
"vacationIconLabel": "Modo vacaciones",
|
"vacationIconLabel": "Modo vacaciones",
|
||||||
|
"alarmAdvancedSectionTitle": "Avanzado",
|
||||||
|
"alarmInlineHourLabel": "Hora",
|
||||||
|
"alarmInlineMinuteLabel": "Minuto",
|
||||||
|
"alarmVolumeRisingStatus": "Subiendo volumen",
|
||||||
"streamUrlHint": "https://stream.example.com:8000/radio",
|
"streamUrlHint": "https://stream.example.com:8000/radio",
|
||||||
"@stationCount": {
|
"@stationCount": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -496,6 +496,72 @@ abstract class AppLocalizations {
|
|||||||
/// **'Usando EQ principal para {stationName}'**
|
/// **'Usando EQ principal para {stationName}'**
|
||||||
String equalizerPerStationMain(Object stationName);
|
String equalizerPerStationMain(Object stationName);
|
||||||
|
|
||||||
|
/// No description provided for @equalizerBaseExplainer.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.'**
|
||||||
|
String get equalizerBaseExplainer;
|
||||||
|
|
||||||
|
/// No description provided for @equalizerActiveOutputLabel.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Salida activa'**
|
||||||
|
String get equalizerActiveOutputLabel;
|
||||||
|
|
||||||
|
/// No description provided for @equalizerActiveOutputDefault.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'El altavoz de este dispositivo'**
|
||||||
|
String get equalizerActiveOutputDefault;
|
||||||
|
|
||||||
|
/// No description provided for @equalizerStationsWithOwnEqTitle.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Emisoras con ajuste propio'**
|
||||||
|
String get equalizerStationsWithOwnEqTitle;
|
||||||
|
|
||||||
|
/// No description provided for @equalizerStationsWithOwnEqSubtitle.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Ignoran este ecualizador base'**
|
||||||
|
String get equalizerStationsWithOwnEqSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @equalizerStationsWithOwnEqEmpty.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Ninguna emisora tiene ajuste propio todavía.'**
|
||||||
|
String get equalizerStationsWithOwnEqEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @equalizerSaveAsPresetAction.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Guardar como preset'**
|
||||||
|
String get equalizerSaveAsPresetAction;
|
||||||
|
|
||||||
|
/// No description provided for @equalizerSavePresetDialogTitle.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Guardar como preset'**
|
||||||
|
String get equalizerSavePresetDialogTitle;
|
||||||
|
|
||||||
|
/// No description provided for @equalizerSavePresetNameLabel.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Nombre del preset'**
|
||||||
|
String get equalizerSavePresetNameLabel;
|
||||||
|
|
||||||
|
/// No description provided for @equalizerSavePresetEmptyNameError.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Ingresá un nombre para el preset.'**
|
||||||
|
String get equalizerSavePresetEmptyNameError;
|
||||||
|
|
||||||
|
/// No description provided for @equalizerSavePresetConfirm.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Guardar'**
|
||||||
|
String get equalizerSavePresetConfirm;
|
||||||
|
|
||||||
/// No description provided for @preferredStationTitle.
|
/// No description provided for @preferredStationTitle.
|
||||||
///
|
///
|
||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
@@ -2414,6 +2480,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Pausar reproducción'**
|
/// **'Pausar reproducción'**
|
||||||
String get pausePlaybackTooltip;
|
String get pausePlaybackTooltip;
|
||||||
|
|
||||||
|
/// No description provided for @playerQualityChangeAction.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Cambiar'**
|
||||||
|
String get playerQualityChangeAction;
|
||||||
|
|
||||||
|
/// No description provided for @playerToolEqLabel.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'EQ propio'**
|
||||||
|
String get playerToolEqLabel;
|
||||||
|
|
||||||
/// No description provided for @qualityOriginal.
|
/// No description provided for @qualityOriginal.
|
||||||
///
|
///
|
||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
@@ -2564,6 +2642,30 @@ abstract class AppLocalizations {
|
|||||||
/// **'Modo vacaciones'**
|
/// **'Modo vacaciones'**
|
||||||
String get vacationIconLabel;
|
String get vacationIconLabel;
|
||||||
|
|
||||||
|
/// No description provided for @alarmAdvancedSectionTitle.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Avanzado'**
|
||||||
|
String get alarmAdvancedSectionTitle;
|
||||||
|
|
||||||
|
/// No description provided for @alarmInlineHourLabel.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Hora'**
|
||||||
|
String get alarmInlineHourLabel;
|
||||||
|
|
||||||
|
/// No description provided for @alarmInlineMinuteLabel.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Minuto'**
|
||||||
|
String get alarmInlineMinuteLabel;
|
||||||
|
|
||||||
|
/// No description provided for @alarmVolumeRisingStatus.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Subiendo volumen'**
|
||||||
|
String get alarmVolumeRisingStatus;
|
||||||
|
|
||||||
/// No description provided for @streamUrlHint.
|
/// No description provided for @streamUrlHint.
|
||||||
///
|
///
|
||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
|
|||||||
@@ -223,6 +223,43 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
return 'استخدام المعادل الرئيسي لـ $stationName';
|
return 'استخدام المعادل الرئيسي لـ $stationName';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerBaseExplainer =>
|
||||||
|
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputLabel => 'Salida activa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqSubtitle =>
|
||||||
|
'Ignoran este ecualizador base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqEmpty =>
|
||||||
|
'Ninguna emisora tiene ajuste propio todavía.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSaveAsPresetAction => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetNameLabel => 'Nombre del preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetEmptyNameError =>
|
||||||
|
'Ingresá un nombre para el preset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetConfirm => 'Guardar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferredStationTitle => 'المحطة المفضلة';
|
String get preferredStationTitle => 'المحطة المفضلة';
|
||||||
|
|
||||||
@@ -1323,6 +1360,12 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'إيقاف مؤقت';
|
String get pausePlaybackTooltip => 'إيقاف مؤقت';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'الجودة الأصلية: $quality';
|
return 'الجودة الأصلية: $quality';
|
||||||
@@ -1410,6 +1453,18 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'وضع الإجازة';
|
String get vacationIconLabel => 'وضع الإجازة';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmAdvancedSectionTitle => 'Avanzado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineHourLabel => 'Hora';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineMinuteLabel => 'Minuto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmVolumeRisingStatus => 'Subiendo volumen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,43 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
return '$stationName-এর জন্য মূল ইকুয়ালাইজার ব্যবহার করা হচ্ছে';
|
return '$stationName-এর জন্য মূল ইকুয়ালাইজার ব্যবহার করা হচ্ছে';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerBaseExplainer =>
|
||||||
|
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputLabel => 'Salida activa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqSubtitle =>
|
||||||
|
'Ignoran este ecualizador base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqEmpty =>
|
||||||
|
'Ninguna emisora tiene ajuste propio todavía.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSaveAsPresetAction => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetNameLabel => 'Nombre del preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetEmptyNameError =>
|
||||||
|
'Ingresá un nombre para el preset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetConfirm => 'Guardar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferredStationTitle => 'পছন্দের স্টেশন';
|
String get preferredStationTitle => 'পছন্দের স্টেশন';
|
||||||
|
|
||||||
@@ -1332,6 +1369,12 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'প্লেব্যাক বিরতি';
|
String get pausePlaybackTooltip => 'প্লেব্যাক বিরতি';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'মূল মান: $quality';
|
return 'মূল মান: $quality';
|
||||||
@@ -1417,6 +1460,18 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'ছুটির মোড';
|
String get vacationIconLabel => 'ছুটির মোড';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmAdvancedSectionTitle => 'Avanzado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineHourLabel => 'Hora';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineMinuteLabel => 'Minuto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmVolumeRisingStatus => 'Subiendo volumen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,43 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return 'Haupt-EQ für $stationName wird verwendet';
|
return 'Haupt-EQ für $stationName wird verwendet';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerBaseExplainer =>
|
||||||
|
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputLabel => 'Salida activa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqSubtitle =>
|
||||||
|
'Ignoran este ecualizador base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqEmpty =>
|
||||||
|
'Ninguna emisora tiene ajuste propio todavía.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSaveAsPresetAction => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetNameLabel => 'Nombre del preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetEmptyNameError =>
|
||||||
|
'Ingresá un nombre para el preset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetConfirm => 'Guardar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferredStationTitle => 'Bevorzugter Sender';
|
String get preferredStationTitle => 'Bevorzugter Sender';
|
||||||
|
|
||||||
@@ -1340,6 +1377,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Wiedergabe pausieren';
|
String get pausePlaybackTooltip => 'Wiedergabe pausieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Originalqualität: $quality';
|
return 'Originalqualität: $quality';
|
||||||
@@ -1427,6 +1470,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Urlaubsmodus';
|
String get vacationIconLabel => 'Urlaubsmodus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmAdvancedSectionTitle => 'Avanzado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineHourLabel => 'Hora';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineMinuteLabel => 'Minuto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmVolumeRisingStatus => 'Subiendo volumen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,42 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return 'Using main EQ for $stationName';
|
return 'Using main EQ for $stationName';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerBaseExplainer =>
|
||||||
|
'This is the base equalizer: it applies to every station without its own setting. A station\'s own EQ is set from its own playback screen and overrides this one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputLabel => 'Active output';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputDefault => 'This device\'s speaker';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqTitle => 'Stations with their own EQ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqSubtitle => 'Ignore this base equalizer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqEmpty =>
|
||||||
|
'No stations have their own EQ yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSaveAsPresetAction => 'Save as preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetDialogTitle => 'Save as preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetNameLabel => 'Preset name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetEmptyNameError =>
|
||||||
|
'Enter a name for the preset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetConfirm => 'Save';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferredStationTitle => 'Preferred station';
|
String get preferredStationTitle => 'Preferred station';
|
||||||
|
|
||||||
@@ -1325,6 +1361,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Pause playback';
|
String get pausePlaybackTooltip => 'Pause playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Change';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'Own EQ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Original quality: $quality';
|
return 'Original quality: $quality';
|
||||||
@@ -1411,6 +1453,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Vacation mode';
|
String get vacationIconLabel => 'Vacation mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmAdvancedSectionTitle => 'Advanced';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineHourLabel => 'Hour';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineMinuteLabel => 'Minute';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmVolumeRisingStatus => 'Turning up the volume';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,43 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return 'Usando EQ principal para $stationName';
|
return 'Usando EQ principal para $stationName';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerBaseExplainer =>
|
||||||
|
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputLabel => 'Salida activa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqSubtitle =>
|
||||||
|
'Ignoran este ecualizador base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqEmpty =>
|
||||||
|
'Ninguna emisora tiene ajuste propio todavía.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSaveAsPresetAction => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetNameLabel => 'Nombre del preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetEmptyNameError =>
|
||||||
|
'Ingresá un nombre para el preset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetConfirm => 'Guardar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferredStationTitle => 'Emisora preferida';
|
String get preferredStationTitle => 'Emisora preferida';
|
||||||
|
|
||||||
@@ -1335,6 +1372,12 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Pausar reproducción';
|
String get pausePlaybackTooltip => 'Pausar reproducción';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Calidad original: $quality';
|
return 'Calidad original: $quality';
|
||||||
@@ -1422,6 +1465,18 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Modo vacaciones';
|
String get vacationIconLabel => 'Modo vacaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmAdvancedSectionTitle => 'Avanzado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineHourLabel => 'Hora';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineMinuteLabel => 'Minuto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmVolumeRisingStatus => 'Subiendo volumen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,43 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return 'EQ principal utilisé pour $stationName';
|
return 'EQ principal utilisé pour $stationName';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerBaseExplainer =>
|
||||||
|
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputLabel => 'Salida activa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqSubtitle =>
|
||||||
|
'Ignoran este ecualizador base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqEmpty =>
|
||||||
|
'Ninguna emisora tiene ajuste propio todavía.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSaveAsPresetAction => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetNameLabel => 'Nombre del preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetEmptyNameError =>
|
||||||
|
'Ingresá un nombre para el preset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetConfirm => 'Guardar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferredStationTitle => 'Station préférée';
|
String get preferredStationTitle => 'Station préférée';
|
||||||
|
|
||||||
@@ -1345,6 +1382,12 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Mettre en pause';
|
String get pausePlaybackTooltip => 'Mettre en pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Qualité d’origine : $quality';
|
return 'Qualité d’origine : $quality';
|
||||||
@@ -1432,6 +1475,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Mode vacances';
|
String get vacationIconLabel => 'Mode vacances';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmAdvancedSectionTitle => 'Avanzado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineHourLabel => 'Hora';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineMinuteLabel => 'Minuto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmVolumeRisingStatus => 'Subiendo volumen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,43 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return '$stationName के लिए मुख्य EQ इस्तेमाल हो रहा है';
|
return '$stationName के लिए मुख्य EQ इस्तेमाल हो रहा है';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerBaseExplainer =>
|
||||||
|
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputLabel => 'Salida activa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqSubtitle =>
|
||||||
|
'Ignoran este ecualizador base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqEmpty =>
|
||||||
|
'Ninguna emisora tiene ajuste propio todavía.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSaveAsPresetAction => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetNameLabel => 'Nombre del preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetEmptyNameError =>
|
||||||
|
'Ingresá un nombre para el preset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetConfirm => 'Guardar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferredStationTitle => 'पसंदीदा स्टेशन';
|
String get preferredStationTitle => 'पसंदीदा स्टेशन';
|
||||||
|
|
||||||
@@ -1330,6 +1367,12 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'प्लेबैक रोकें';
|
String get pausePlaybackTooltip => 'प्लेबैक रोकें';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'मूल गुणवत्ता: $quality';
|
return 'मूल गुणवत्ता: $quality';
|
||||||
@@ -1416,6 +1459,18 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'अवकाश मोड';
|
String get vacationIconLabel => 'अवकाश मोड';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmAdvancedSectionTitle => 'Avanzado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineHourLabel => 'Hora';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineMinuteLabel => 'Minuto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmVolumeRisingStatus => 'Subiendo volumen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,43 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return 'Menggunakan EQ utama untuk $stationName';
|
return 'Menggunakan EQ utama untuk $stationName';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerBaseExplainer =>
|
||||||
|
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputLabel => 'Salida activa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqSubtitle =>
|
||||||
|
'Ignoran este ecualizador base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqEmpty =>
|
||||||
|
'Ninguna emisora tiene ajuste propio todavía.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSaveAsPresetAction => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetNameLabel => 'Nombre del preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetEmptyNameError =>
|
||||||
|
'Ingresá un nombre para el preset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetConfirm => 'Guardar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferredStationTitle => 'Stasiun pilihan';
|
String get preferredStationTitle => 'Stasiun pilihan';
|
||||||
|
|
||||||
@@ -1336,6 +1373,12 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Jeda pemutaran';
|
String get pausePlaybackTooltip => 'Jeda pemutaran';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Kualitas asli: $quality';
|
return 'Kualitas asli: $quality';
|
||||||
@@ -1421,6 +1464,18 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Mode liburan';
|
String get vacationIconLabel => 'Mode liburan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmAdvancedSectionTitle => 'Avanzado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineHourLabel => 'Hora';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineMinuteLabel => 'Minuto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmVolumeRisingStatus => 'Subiendo volumen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,43 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
return 'EQ principale in uso per $stationName';
|
return 'EQ principale in uso per $stationName';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerBaseExplainer =>
|
||||||
|
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputLabel => 'Salida activa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqSubtitle =>
|
||||||
|
'Ignoran este ecualizador base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqEmpty =>
|
||||||
|
'Ninguna emisora tiene ajuste propio todavía.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSaveAsPresetAction => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetNameLabel => 'Nombre del preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetEmptyNameError =>
|
||||||
|
'Ingresá un nombre para el preset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetConfirm => 'Guardar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferredStationTitle => 'Emittente preferita';
|
String get preferredStationTitle => 'Emittente preferita';
|
||||||
|
|
||||||
@@ -1340,6 +1377,12 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Pausa riproduzione';
|
String get pausePlaybackTooltip => 'Pausa riproduzione';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Qualità originale: $quality';
|
return 'Qualità originale: $quality';
|
||||||
@@ -1427,6 +1470,18 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Modalità vacanza';
|
String get vacationIconLabel => 'Modalità vacanza';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmAdvancedSectionTitle => 'Avanzado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineHourLabel => 'Hora';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineMinuteLabel => 'Minuto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmVolumeRisingStatus => 'Subiendo volumen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -217,6 +217,43 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return '$stationName でメインEQを使用中';
|
return '$stationName でメインEQを使用中';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerBaseExplainer =>
|
||||||
|
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputLabel => 'Salida activa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqSubtitle =>
|
||||||
|
'Ignoran este ecualizador base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqEmpty =>
|
||||||
|
'Ninguna emisora tiene ajuste propio todavía.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSaveAsPresetAction => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetNameLabel => 'Nombre del preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetEmptyNameError =>
|
||||||
|
'Ingresá un nombre para el preset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetConfirm => 'Guardar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferredStationTitle => '優先局';
|
String get preferredStationTitle => '優先局';
|
||||||
|
|
||||||
@@ -1292,6 +1329,12 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => '再生を一時停止';
|
String get pausePlaybackTooltip => '再生を一時停止';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return '元の音質: $quality';
|
return '元の音質: $quality';
|
||||||
@@ -1376,6 +1419,18 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => '休暇モード';
|
String get vacationIconLabel => '休暇モード';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmAdvancedSectionTitle => 'Avanzado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineHourLabel => 'Hora';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineMinuteLabel => 'Minuto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmVolumeRisingStatus => 'Subiendo volumen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,43 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
return 'Usando o EQ principal para $stationName';
|
return 'Usando o EQ principal para $stationName';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerBaseExplainer =>
|
||||||
|
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputLabel => 'Salida activa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqSubtitle =>
|
||||||
|
'Ignoran este ecualizador base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqEmpty =>
|
||||||
|
'Ninguna emisora tiene ajuste propio todavía.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSaveAsPresetAction => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetNameLabel => 'Nombre del preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetEmptyNameError =>
|
||||||
|
'Ingresá un nombre para el preset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetConfirm => 'Guardar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferredStationTitle => 'Estação preferida';
|
String get preferredStationTitle => 'Estação preferida';
|
||||||
|
|
||||||
@@ -1332,6 +1369,12 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Pausar reprodução';
|
String get pausePlaybackTooltip => 'Pausar reprodução';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Qualidade original: $quality';
|
return 'Qualidade original: $quality';
|
||||||
@@ -1419,6 +1462,18 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Modo férias';
|
String get vacationIconLabel => 'Modo férias';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmAdvancedSectionTitle => 'Avanzado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineHourLabel => 'Hora';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineMinuteLabel => 'Minuto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmVolumeRisingStatus => 'Subiendo volumen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,43 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return 'Используется основной EQ для $stationName';
|
return 'Используется основной EQ для $stationName';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerBaseExplainer =>
|
||||||
|
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputLabel => 'Salida activa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqSubtitle =>
|
||||||
|
'Ignoran este ecualizador base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqEmpty =>
|
||||||
|
'Ninguna emisora tiene ajuste propio todavía.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSaveAsPresetAction => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetNameLabel => 'Nombre del preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetEmptyNameError =>
|
||||||
|
'Ingresá un nombre para el preset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetConfirm => 'Guardar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferredStationTitle => 'Предпочитаемая станция';
|
String get preferredStationTitle => 'Предпочитаемая станция';
|
||||||
|
|
||||||
@@ -1336,6 +1373,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Пауза';
|
String get pausePlaybackTooltip => 'Пауза';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Исходное качество: $quality';
|
return 'Исходное качество: $quality';
|
||||||
@@ -1423,6 +1466,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Режим отпуска';
|
String get vacationIconLabel => 'Режим отпуска';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmAdvancedSectionTitle => 'Avanzado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineHourLabel => 'Hora';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineMinuteLabel => 'Minuto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmVolumeRisingStatus => 'Subiendo volumen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,43 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return '正在为 $stationName 使用主均衡器';
|
return '正在为 $stationName 使用主均衡器';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerBaseExplainer =>
|
||||||
|
'Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputLabel => 'Salida activa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerActiveOutputDefault => 'El altavoz de este dispositivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqTitle => 'Emisoras con ajuste propio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqSubtitle =>
|
||||||
|
'Ignoran este ecualizador base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerStationsWithOwnEqEmpty =>
|
||||||
|
'Ninguna emisora tiene ajuste propio todavía.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSaveAsPresetAction => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetDialogTitle => 'Guardar como preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetNameLabel => 'Nombre del preset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetEmptyNameError =>
|
||||||
|
'Ingresá un nombre para el preset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get equalizerSavePresetConfirm => 'Guardar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferredStationTitle => '首选电台';
|
String get preferredStationTitle => '首选电台';
|
||||||
|
|
||||||
@@ -1287,6 +1324,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => '暂停播放';
|
String get pausePlaybackTooltip => '暂停播放';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return '原始质量:$quality';
|
return '原始质量:$quality';
|
||||||
@@ -1371,6 +1414,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => '假期模式';
|
String get vacationIconLabel => '假期模式';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmAdvancedSectionTitle => 'Avanzado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineHourLabel => 'Hora';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmInlineMinuteLabel => 'Minuto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alarmVolumeRisingStatus => 'Subiendo volumen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../estado/estado_ecualizador.dart';
|
import '../../estado/estado_ecualizador.dart';
|
||||||
import '../../estado/estado_radio.dart';
|
import '../../estado/estado_radio.dart';
|
||||||
import '../../l10n/gen/app_localizations.dart';
|
import '../../l10n/gen/app_localizations.dart';
|
||||||
|
import '../../tema/pluriwave_theme.dart';
|
||||||
import '../../widgets/ecualizador_widget.dart';
|
import '../../widgets/ecualizador_widget.dart';
|
||||||
import '../../widgets/pluri_glass_surface.dart';
|
import '../../widgets/pluri_glass_surface.dart';
|
||||||
import '../../widgets/pluri_layout.dart';
|
import '../../widgets/pluri_layout.dart';
|
||||||
@@ -15,8 +18,10 @@ import '../../widgets/pluri_push_scaffold.dart';
|
|||||||
/// [PluriPushScaffold] now carries the title and the very next row already
|
/// [PluriPushScaffold] now carries the title and the very next row already
|
||||||
/// shows the same active/disabled state.
|
/// shows the same active/disabled state.
|
||||||
///
|
///
|
||||||
/// This body is a placeholder pending WU13's restyle (ADR-3's own component
|
/// WU13 (design ADR-5) restyled [EcualizadorWidget] itself and added the
|
||||||
/// inventory: "body rewritten by WU13").
|
/// base-vs-per-station explainer, the "Salida activa" summary row, the
|
||||||
|
/// "Emisoras con ajuste propio" drill-down, and the "Guardar como preset"
|
||||||
|
/// custom-preset flow — see `_CuerpoEcualizador` below.
|
||||||
class PantallaAjustesEcualizador extends StatelessWidget {
|
class PantallaAjustesEcualizador extends StatelessWidget {
|
||||||
const PantallaAjustesEcualizador({super.key});
|
const PantallaAjustesEcualizador({super.key});
|
||||||
|
|
||||||
@@ -30,13 +35,32 @@ class PantallaAjustesEcualizador extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CuerpoEcualizador extends StatelessWidget {
|
class _CuerpoEcualizador extends StatefulWidget {
|
||||||
const _CuerpoEcualizador();
|
const _CuerpoEcualizador();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CuerpoEcualizador> createState() => _CuerpoEcualizadorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CuerpoEcualizadorState extends State<_CuerpoEcualizador> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final eq = context.read<EstadoEcualizador>();
|
||||||
|
// Fire-and-forget, mirroring the established
|
||||||
|
// `pantalla_ajustes_salida_audio.dart` pattern: both calls are
|
||||||
|
// genuinely async (a SharedPreferences read / a native re-query), so
|
||||||
|
// their completion never lands inside THIS build — no
|
||||||
|
// "setState() during build" risk.
|
||||||
|
unawaited(eq.cargarPresetsPersonalizados());
|
||||||
|
unawaited(eq.refrescarDispositivoActual());
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// EQ state comes from EstadoEcualizador (S4-R1/S4-R5); EstadoRadio is
|
// EQ state comes from EstadoEcualizador (S4-R1/S4-R5); EstadoRadio is
|
||||||
// only consulted for the current station + favorite flag.
|
// only consulted for the current station + favorite flag and for
|
||||||
|
// resolving station names in the "ajuste propio" drill-down.
|
||||||
return Consumer2<EstadoRadio, EstadoEcualizador>(
|
return Consumer2<EstadoRadio, EstadoEcualizador>(
|
||||||
builder: (ctx, estado, eq, _) {
|
builder: (ctx, estado, eq, _) {
|
||||||
final disponible = eq.disponible;
|
final disponible = eq.disponible;
|
||||||
@@ -77,16 +101,34 @@ class _CuerpoEcualizador extends StatelessWidget {
|
|||||||
eq.cambiarModoEmisoraActual(usarPropio: usarPropio),
|
eq.cambiarModoEmisoraActual(usarPropio: usarPropio),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_BannerExplicacionBase(l10n: l10n),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
_FilaSalidaActiva(eq: eq, l10n: l10n),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_FilaEmisorasConAjustePropio(eq: eq, estado: estado, l10n: l10n),
|
||||||
|
const SizedBox(height: 12),
|
||||||
PresetsEcualizadorWidget(
|
PresetsEcualizadorWidget(
|
||||||
presetActual: eq.presetActual,
|
presetActual: eq.presetActual,
|
||||||
|
personalizados: eq.presetsPersonalizados,
|
||||||
onSeleccionar: (p) => eq.cambiarPreset(p),
|
onSeleccionar: (p) => eq.cambiarPreset(p),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
EcualizadorWidget(
|
EcualizadorWidget(
|
||||||
preset: eq.presetActual,
|
preset: eq.presetActual,
|
||||||
|
habilitado: eq.activo,
|
||||||
onCambio: (p) => eq.cambiarPreset(p),
|
onCambio: (p) => eq.cambiarPreset(p),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton.icon(
|
||||||
|
key: const Key('eq-save-preset-action'),
|
||||||
|
onPressed: () => _abrirDialogoGuardarPreset(context, eq),
|
||||||
|
icon: const Icon(Icons.bookmark_add_outlined, size: 20),
|
||||||
|
label: Text(l10n.equalizerSaveAsPresetAction),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -94,3 +136,281 @@ class _CuerpoEcualizador extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _abrirDialogoGuardarPreset(
|
||||||
|
BuildContext context,
|
||||||
|
EstadoEcualizador eq,
|
||||||
|
) {
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _DialogoGuardarPreset(eq: eq),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base-vs-per-station explainer (spec `eq-custom-presets` "Base-vs-Per-
|
||||||
|
/// Station Explainer Preserved"): always visible, distinguishing the base
|
||||||
|
/// (global/device) EQ this screen edits from a station's own override.
|
||||||
|
class _BannerExplicacionBase extends StatelessWidget {
|
||||||
|
const _BannerExplicacionBase({required this.l10n});
|
||||||
|
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tokens = context.pluriTokens;
|
||||||
|
return Container(
|
||||||
|
key: const Key('eq-base-explainer-banner'),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tokens.liveGreen.withValues(alpha: 0.09),
|
||||||
|
borderRadius: BorderRadius.circular(tokens.radiusSm),
|
||||||
|
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.26)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline_rounded, size: 20, color: tokens.liveGreen),
|
||||||
|
const SizedBox(width: 11),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.equalizerBaseExplainer,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Salida activa" row (spec `eq-custom-presets` "Active Output Surfaced on
|
||||||
|
/// the Main Screen"): surfaced here instead of only inside the Advanced
|
||||||
|
/// (multi-device) screen, and kept live via the SAME `notifyListeners()`
|
||||||
|
/// calls `_onDispositivoCambiado` already fires — no new plumbing needed
|
||||||
|
/// beyond reading `dispositivoActualId` here.
|
||||||
|
class _FilaSalidaActiva extends StatelessWidget {
|
||||||
|
const _FilaSalidaActiva({required this.eq, required this.l10n});
|
||||||
|
|
||||||
|
final EstadoEcualizador eq;
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final deviceId = eq.dispositivoActualId;
|
||||||
|
final nombre =
|
||||||
|
deviceId == null
|
||||||
|
? l10n.equalizerActiveOutputDefault
|
||||||
|
: eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId));
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
key: const Key('eq-active-output-row'),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.speaker_group_rounded, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.equalizerActiveOutputLabel,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
nombre,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Emisoras con ajuste propio" drill-down row (spec `eq-custom-presets`
|
||||||
|
/// "Stations-With-Own-EQ Drill-Down"): sourced from the existing
|
||||||
|
/// `presetsPorEmisora` map, no new state.
|
||||||
|
class _FilaEmisorasConAjustePropio extends StatelessWidget {
|
||||||
|
const _FilaEmisorasConAjustePropio({
|
||||||
|
required this.eq,
|
||||||
|
required this.estado,
|
||||||
|
required this.l10n,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EstadoEcualizador eq;
|
||||||
|
final EstadoRadio estado;
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final uuids = eq.presetsPorEmisora.keys.toList();
|
||||||
|
return Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: InkWell(
|
||||||
|
key: const Key('eq-stations-own-eq-row'),
|
||||||
|
borderRadius: BorderRadius.circular(context.pluriTokens.radiusSm),
|
||||||
|
onTap:
|
||||||
|
() => PluriPushScaffold.push(
|
||||||
|
context,
|
||||||
|
(_) => _PantallaEmisorasConAjustePropio(
|
||||||
|
uuids: uuids,
|
||||||
|
nombrePorUuid: (uuid) => _resolverNombreEmisora(estado, uuid),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.tune_rounded, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.equalizerStationsWithOwnEqTitle,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
l10n.equalizerStationsWithOwnEqSubtitle,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
key: const Key('eq-stations-own-eq-count'),
|
||||||
|
'${uuids.length}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Icon(Icons.chevron_right_rounded, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a station uuid to its display name via [EstadoRadio.listaFavoritos]
|
||||||
|
/// (the only local, synchronous list of known stations) — falls back to the
|
||||||
|
/// raw uuid for a station that has its own EQ but is not (or no longer) a
|
||||||
|
/// favorite, e.g. one set from the player's per-station EQ sheet (WU14).
|
||||||
|
String _resolverNombreEmisora(EstadoRadio estado, String uuid) {
|
||||||
|
for (final emisora in estado.listaFavoritos) {
|
||||||
|
if (emisora.uuid == uuid) return emisora.nombre;
|
||||||
|
}
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destination screen for the drill-down row above.
|
||||||
|
class _PantallaEmisorasConAjustePropio extends StatelessWidget {
|
||||||
|
const _PantallaEmisorasConAjustePropio({
|
||||||
|
required this.uuids,
|
||||||
|
required this.nombrePorUuid,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<String> uuids;
|
||||||
|
final String Function(String uuid) nombrePorUuid;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return PluriPushScaffold(
|
||||||
|
title: l10n.equalizerStationsWithOwnEqTitle,
|
||||||
|
body:
|
||||||
|
uuids.isEmpty
|
||||||
|
? Center(child: Text(l10n.equalizerStationsWithOwnEqEmpty))
|
||||||
|
: ListView.separated(
|
||||||
|
padding: PluriLayout.pageContentPadding,
|
||||||
|
itemCount: uuids.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 4),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final uuid = uuids[i];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.radio_rounded, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(nombrePorUuid(uuid))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Guardar como preset" dialog (spec `eq-custom-presets` "Custom Preset
|
||||||
|
/// Save" / "Custom Preset Naming Validates Non-Empty Input").
|
||||||
|
class _DialogoGuardarPreset extends StatefulWidget {
|
||||||
|
const _DialogoGuardarPreset({required this.eq});
|
||||||
|
|
||||||
|
final EstadoEcualizador eq;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_DialogoGuardarPreset> createState() => _DialogoGuardarPresetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DialogoGuardarPresetState extends State<_DialogoGuardarPreset> {
|
||||||
|
late final TextEditingController _nombreCtrl;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_nombreCtrl = TextEditingController();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nombreCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmar() async {
|
||||||
|
final guardado = await widget.eq.guardarPresetPersonalizado(
|
||||||
|
_nombreCtrl.text,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!guardado) {
|
||||||
|
setState(
|
||||||
|
() =>
|
||||||
|
_error =
|
||||||
|
AppLocalizations.of(context).equalizerSavePresetEmptyNameError,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(l10n.equalizerSavePresetDialogTitle),
|
||||||
|
content: TextField(
|
||||||
|
key: const Key('eq-save-preset-name-field'),
|
||||||
|
controller: _nombreCtrl,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.equalizerSavePresetNameLabel,
|
||||||
|
errorText: _error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
FilledButton(
|
||||||
|
key: const Key('eq-save-preset-confirm-button'),
|
||||||
|
onPressed: _confirmar,
|
||||||
|
child: Text(l10n.equalizerSavePresetConfirm),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -11,7 +12,6 @@ import '../modelos/alarma_musical.dart';
|
|||||||
import '../tema/pluri_animate.dart';
|
import '../tema/pluri_animate.dart';
|
||||||
import '../tema/pluriwave_theme.dart';
|
import '../tema/pluriwave_theme.dart';
|
||||||
import '../tema/pluriwave_tokens.dart';
|
import '../tema/pluriwave_tokens.dart';
|
||||||
import '../widgets/pluri_glass_surface.dart';
|
|
||||||
import '../widgets/pluri_wave_scaffold.dart';
|
import '../widgets/pluri_wave_scaffold.dart';
|
||||||
|
|
||||||
class PantallaAlarmaSonando extends StatefulWidget {
|
class PantallaAlarmaSonando extends StatefulWidget {
|
||||||
@@ -153,13 +153,6 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<int> _opcionesSnooze() {
|
|
||||||
final opciones = <int>{3, 5, 10};
|
|
||||||
final propio = widget.alarma.snoozeMinutos;
|
|
||||||
if (propio > 0) opciones.add(propio);
|
|
||||||
return opciones.toList()..sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_alarmas.removeListener(_alReconciliarFinExterno);
|
_alarmas.removeListener(_alReconciliarFinExterno);
|
||||||
@@ -195,39 +188,53 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
|||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
PluriWaveTokens tokens,
|
PluriWaveTokens tokens,
|
||||||
) {
|
) {
|
||||||
|
final type = context.pluriType;
|
||||||
|
// WU11 (native-alarms delta — restyle, drop live countdown label):
|
||||||
|
// full-bleed blurred art replaces the PluriGlassSurface card. Cold-GPU
|
||||||
|
// note (Design 2.4) still applies to the entry animation below, which
|
||||||
|
// is why it stays on the foreground content only, not the background.
|
||||||
return PluriWaveScaffold(
|
return PluriWaveScaffold(
|
||||||
body: SafeArea(
|
body: Stack(
|
||||||
child: Padding(
|
fit: StackFit.expand,
|
||||||
padding: const EdgeInsets.all(20),
|
children: [
|
||||||
child: Center(
|
_FondoArteDifuminado(tokens: tokens),
|
||||||
child: PluriGlassSurface(
|
SafeArea(
|
||||||
borderRadius: BorderRadius.circular(32),
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
|
||||||
blurSigma: 10,
|
|
||||||
glowColor: tokens.warmCoral.withValues(alpha: 0.35),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
Image.asset(
|
const Spacer(flex: 2),
|
||||||
'assets/icons/alarmas/alarm_music.png',
|
FittedBox(
|
||||||
width: 128,
|
fit: BoxFit.scaleDown,
|
||||||
height: 128,
|
child: Text(
|
||||||
),
|
_hora(alarma),
|
||||||
const SizedBox(height: 16),
|
key: const ValueKey('ringing-hero-time'),
|
||||||
Text(
|
style: type.heroTime,
|
||||||
_hora(alarma),
|
|
||||||
style: Theme.of(context).textTheme.displayMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
letterSpacing: -2,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
localizedAlarmName(l10n, alarma.nombre),
|
localizedAlarmName(l10n, alarma.nombre),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: type.bodyStrong,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 22),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/icons/alarmas/alarm_music.png',
|
||||||
|
width: 168,
|
||||||
|
height: 168,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder:
|
||||||
|
(_, __, ___) => Icon(
|
||||||
|
Icons.music_note_rounded,
|
||||||
|
size: 96,
|
||||||
|
color: tokens.warmCoral,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
// Static status line (Design D8): sourced only from
|
// Static status line (Design D8): sourced only from
|
||||||
// widget.alarma, never from a live audio/player state — the
|
// widget.alarma, never from a live audio/player state — the
|
||||||
// ring's own audio state is owned natively and this screen
|
// ring's own audio state is owned natively and this screen
|
||||||
@@ -237,31 +244,54 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
|||||||
? localizedStationName(l10n, alarma.emisora!.nombre)
|
? localizedStationName(l10n, alarma.emisora!.nombre)
|
||||||
: l10n.alarmRingingNotificationTitle,
|
: l10n.alarmRingingNotificationTitle,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
style: type.cardTitle,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
// WU11 (native-alarms delta — Ringing Screen Shows a
|
||||||
Text(
|
// Static Status Label): only rendered while this alarm was
|
||||||
l10n.snoozeAction,
|
// actually configured with a fade-in; a STATIC label, no
|
||||||
style: Theme.of(context).textTheme.labelLarge,
|
// seconds suffix, no ticking value — the native→Flutter
|
||||||
|
// progress channel that a live countdown would need is
|
||||||
|
// deliberately absent from this architecture (resolution
|
||||||
|
// 4). Not spec-tested to also disappear once the fade-in
|
||||||
|
// period elapses: this screen has no clock signal to know
|
||||||
|
// when that is, and inventing one would be exactly the
|
||||||
|
// out-of-scope plumbing being avoided.
|
||||||
|
if (alarma.fadeInSegundos > 0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_EstadoSubidaVolumen(l10n: l10n, tokens: tokens),
|
||||||
|
],
|
||||||
|
const Spacer(flex: 3),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
l10n.snoozeAction,
|
||||||
|
style: type.eyebrowLabel.copyWith(
|
||||||
|
color: tokens.warmCoral,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 10),
|
||||||
Wrap(
|
_FilaSnoozeFija(
|
||||||
spacing: 8,
|
alarma: alarma,
|
||||||
runSpacing: 8,
|
l10n: l10n,
|
||||||
alignment: WrapAlignment.center,
|
tokens: tokens,
|
||||||
children: [
|
onPosponer: _posponer,
|
||||||
for (final minutos in _opcionesSnooze())
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => _posponer(minutos),
|
|
||||||
icon: const Icon(Icons.snooze_rounded),
|
|
||||||
label: Text(l10n.alarmSnoozeOptionLabel(minutos)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
FilledButton.icon(
|
SizedBox(
|
||||||
onPressed: _detener,
|
width: double.infinity,
|
||||||
icon: const Icon(Icons.stop_rounded),
|
child: FilledButton.icon(
|
||||||
label: Text(l10n.stopAlarmAction),
|
key: const ValueKey('ringing-stop-button'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(76),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: _detener,
|
||||||
|
icon: const Icon(Icons.stop_circle_rounded),
|
||||||
|
label: Text(l10n.stopAlarmAction),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (_falloDetencionVisible) ...[
|
if (_falloDetencionVisible) ...[
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
@@ -271,7 +301,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
|||||||
),
|
),
|
||||||
).pluriFadeIn(context),
|
).pluriFadeIn(context),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -310,3 +340,167 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
|||||||
|
|
||||||
String _hora(AlarmaMusical alarma) =>
|
String _hora(AlarmaMusical alarma) =>
|
||||||
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
|
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
/// Full-bleed blurred backdrop (WU11, replaces the `PluriGlassSurface` card
|
||||||
|
/// container per task 11.3). This app has no per-station artwork/favicon
|
||||||
|
/// safe to render here: `Emisora.favicon` is a network URL, and rendering
|
||||||
|
/// one via `Image.network` inside a widget test hangs/throws without a
|
||||||
|
/// mocked `HttpClient` — a hazard no other screen in this codebase accepts
|
||||||
|
/// either. The existing bundled alarm asset is reused instead, heavily
|
||||||
|
/// blurred and stretched; purely decorative, not spec-tested.
|
||||||
|
class _FondoArteDifuminado extends StatelessWidget {
|
||||||
|
const _FondoArteDifuminado({required this.tokens});
|
||||||
|
|
||||||
|
final PluriWaveTokens tokens;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Positioned.fill(
|
||||||
|
key: const ValueKey('ringing-background-art'),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
ImageFiltered(
|
||||||
|
imageFilter: ImageFilter.blur(sigmaX: 44, sigmaY: 44),
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.5,
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/icons/alarmas/alarm_music.png',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
tokens.deepViolet.withValues(alpha: 0.55),
|
||||||
|
tokens.deepViolet.withValues(alpha: 0.9),
|
||||||
|
tokens.deepViolet,
|
||||||
|
],
|
||||||
|
stops: const [0, 0.55, 1],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static "turning up the volume" status (native-alarms delta, WU11 —
|
||||||
|
/// Ringing Screen Shows a Static Status Label): a dot + label, no
|
||||||
|
/// `AnimationController`/`Animate` anywhere in this widget. A pulsing dot
|
||||||
|
/// would reintroduce the exact "`pumpAndSettle()` never terminates" hazard
|
||||||
|
/// WU5 documented for `VisualizadorAudio`'s own repeating controller — this
|
||||||
|
/// screen must stay safe for `pumpAndSettle()` in every other existing test.
|
||||||
|
class _EstadoSubidaVolumen extends StatelessWidget {
|
||||||
|
const _EstadoSubidaVolumen({required this.l10n, required this.tokens});
|
||||||
|
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
final PluriWaveTokens tokens;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
key: const ValueKey('estado-subida-volumen'),
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tokens.liveGreen,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
l10n.alarmVolumeRisingStatus,
|
||||||
|
style: context.pluriType.bodyStrong.copyWith(color: tokens.liveGreen),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ringing screen's snooze row (native-alarms delta, WU11): exactly 3
|
||||||
|
/// FIXED tiles (3/5/10 min), replacing the previous variable-length `Wrap`
|
||||||
|
/// that grew a 4th tile for a custom `snoozeMinutos`. **Design decision, not
|
||||||
|
/// spec-tested** (WU11 has no ADR): the highlighted (filled) tile is
|
||||||
|
/// whichever of the 3 matches `alarma.snoozeMinutos`; the alarm's own
|
||||||
|
/// editor-configured value still decides WHICH tile is filled, tapping any
|
||||||
|
/// tile still snoozes for exactly that tile's duration (`_posponer` is
|
||||||
|
/// called with the tapped value, never the alarm's stored default). If the
|
||||||
|
/// alarm's own value isn't one of the three — only reachable via a fixture
|
||||||
|
/// or a pre-redesign save, since the editor's own snooze picker only ever
|
||||||
|
/// offers `{3, 5, 10, current}` — 10 is the default highlight, matching the
|
||||||
|
/// mockup's own "10 min · habitual" example.
|
||||||
|
class _FilaSnoozeFija extends StatelessWidget {
|
||||||
|
const _FilaSnoozeFija({
|
||||||
|
required this.alarma,
|
||||||
|
required this.l10n,
|
||||||
|
required this.tokens,
|
||||||
|
required this.onPosponer,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AlarmaMusical alarma;
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
final PluriWaveTokens tokens;
|
||||||
|
final ValueChanged<int> onPosponer;
|
||||||
|
|
||||||
|
static const _opciones = [3, 5, 10];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final destacado =
|
||||||
|
_opciones.contains(alarma.snoozeMinutos) ? alarma.snoozeMinutos : 10;
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
for (final minutos in _opciones) ...[
|
||||||
|
if (minutos != _opciones.first) const SizedBox(width: 10),
|
||||||
|
_tile(minutos, minutos == destacado),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tile(int minutos, bool esDestacado) {
|
||||||
|
final forma = RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||||
|
);
|
||||||
|
final etiqueta = Text(l10n.alarmSnoozeOptionLabel(minutos));
|
||||||
|
return Expanded(
|
||||||
|
flex: esDestacado ? 3 : 2,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 76,
|
||||||
|
child:
|
||||||
|
esDestacado
|
||||||
|
? FilledButton(
|
||||||
|
onPressed: () => onPosponer(minutos),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: tokens.warmCoral,
|
||||||
|
foregroundColor: tokens.deepViolet,
|
||||||
|
shape: forma,
|
||||||
|
),
|
||||||
|
child: etiqueta,
|
||||||
|
)
|
||||||
|
: OutlinedButton(
|
||||||
|
onPressed: () => onPosponer(minutos),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: tokens.warmCoral,
|
||||||
|
backgroundColor: tokens.warmCoral.withValues(alpha: 0.16),
|
||||||
|
side: BorderSide(
|
||||||
|
color: tokens.warmCoral.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
shape: forma,
|
||||||
|
),
|
||||||
|
child: etiqueta,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+122
-102
@@ -12,6 +12,7 @@ import '../modelos/emisora.dart';
|
|||||||
import '../servicios/servicio_programacion_alarmas.dart';
|
import '../servicios/servicio_programacion_alarmas.dart';
|
||||||
import '../tema/pluriwave_theme.dart';
|
import '../tema/pluriwave_theme.dart';
|
||||||
import '../tema/pluriwave_tokens.dart';
|
import '../tema/pluriwave_tokens.dart';
|
||||||
|
import '../widgets/editor_hora_inline.dart';
|
||||||
import '../widgets/pluri_glass_surface.dart';
|
import '../widgets/pluri_glass_surface.dart';
|
||||||
import '../widgets/pluri_icon.dart';
|
import '../widgets/pluri_icon.dart';
|
||||||
import '../widgets/pluri_layout.dart';
|
import '../widgets/pluri_layout.dart';
|
||||||
@@ -448,32 +449,17 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
controller: _nombreController,
|
controller: _nombreController,
|
||||||
decoration: InputDecoration(labelText: l10n.nameLabel),
|
decoration: InputDecoration(labelText: l10n.nameLabel),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 16),
|
||||||
Row(
|
// WU10: the native showTimePicker dialog is replaced by a
|
||||||
children: [
|
// giant inline HH:MM editor (drag/tap to adjust); see
|
||||||
Expanded(
|
// `EditorHoraInline`, standalone-tested on its own.
|
||||||
child: _PickerButton(
|
Center(
|
||||||
icon: Icons.schedule_rounded,
|
child: EditorHoraInline(
|
||||||
label: l10n.timeField,
|
value: _hora,
|
||||||
value: _hora.format(context),
|
onChanged: (nuevo) => setState(() => _hora = nuevo),
|
||||||
onTap: _elegirHora,
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: _PickerButton(
|
|
||||||
icon: Icons.event_rounded,
|
|
||||||
label: l10n.dateField,
|
|
||||||
value: _fechaCorta(l10n, _fecha),
|
|
||||||
onTap:
|
|
||||||
_tipo == TipoProgramacionAlarma.unica
|
|
||||||
? _elegirFecha
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 16),
|
||||||
SegmentedButton<TipoProgramacionAlarma>(
|
SegmentedButton<TipoProgramacionAlarma>(
|
||||||
segments: [
|
segments: [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
@@ -493,25 +479,33 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
onSelectionChanged:
|
onSelectionChanged:
|
||||||
(value) => setState(() => _tipo = value.first),
|
(value) => setState(() => _tipo = value.first),
|
||||||
),
|
),
|
||||||
if (_tipo == TipoProgramacionAlarma.diasSemana) ...[
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 10),
|
// WU10: weekday circles are now ALWAYS visible (previously
|
||||||
Wrap(
|
// only inserted into the tree in diasSemana mode) — matching
|
||||||
spacing: 6,
|
// the mockup, which shows them unconditionally under the
|
||||||
children: [
|
// giant time. They stay disabled (onSelected: null, the
|
||||||
for (var i = DateTime.monday; i <= DateTime.sunday; i++)
|
// standard Material "greyed out" FilterChip state) outside
|
||||||
FilterChip(
|
// diasSemana mode rather than being wired to silently mutate
|
||||||
label: Text(_weekdayShort(l10n, i)),
|
// `_diasSemana` while a different `_tipo` is saved — no
|
||||||
selected: _diasSemana.contains(i),
|
// scheduling-data-model change, presentation only.
|
||||||
onSelected:
|
Wrap(
|
||||||
(selected) => setState(() {
|
spacing: 6,
|
||||||
selected
|
children: [
|
||||||
? _diasSemana.add(i)
|
for (var i = DateTime.monday; i <= DateTime.sunday; i++)
|
||||||
: _diasSemana.remove(i);
|
FilterChip(
|
||||||
}),
|
label: Text(_weekdayShort(l10n, i)),
|
||||||
),
|
selected: _diasSemana.contains(i),
|
||||||
],
|
onSelected:
|
||||||
),
|
_tipo == TipoProgramacionAlarma.diasSemana
|
||||||
],
|
? (selected) => setState(() {
|
||||||
|
selected
|
||||||
|
? _diasSemana.add(i)
|
||||||
|
: _diasSemana.remove(i);
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_vistaProximaEjecucion(l10n),
|
_vistaProximaEjecucion(l10n),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
@@ -531,7 +525,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: Text(l10n.alarmFadeInTitle),
|
title: Text(
|
||||||
|
l10n.alarmFadeInTitle,
|
||||||
|
style: context.pluriType.cardTitle,
|
||||||
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
_fadeInSegundos == 0
|
_fadeInSegundos == 0
|
||||||
? l10n.alarmFadeInOff
|
? l10n.alarmFadeInOff
|
||||||
@@ -566,33 +563,11 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
(value) => setState(() => _snoozeMinutos = value.first),
|
(value) => setState(() => _snoozeMinutos = value.first),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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),
|
|
||||||
// S2-R9: searchable bottom-sheet picker instead of a dropdown,
|
// S2-R9: searchable bottom-sheet picker instead of a dropdown,
|
||||||
// for both the primary and the backup (fallback) station.
|
// 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(
|
_CampoSelectorEmisora(
|
||||||
key: const ValueKey('alarm-station-field'),
|
key: const ValueKey('alarm-station-field'),
|
||||||
label: l10n.favoriteStationLabel,
|
label: l10n.favoriteStationLabel,
|
||||||
@@ -608,26 +583,6 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
(emisora) => setState(() => _emisora = emisora),
|
(emisora) => setState(() => _emisora = emisora),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (favoritas.isEmpty) ...[
|
if (favoritas.isEmpty) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(l10n.saveFavoritesAlarmHint),
|
Text(l10n.saveFavoritesAlarmHint),
|
||||||
@@ -658,6 +613,78 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
title: Text(l10n.playDuringVacations),
|
title: Text(l10n.playDuringVacations),
|
||||||
subtitle: Text(l10n.playDuringVacationsHint),
|
subtitle: Text(l10n.playDuringVacationsHint),
|
||||||
),
|
),
|
||||||
|
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),
|
const SizedBox(height: 16),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _guardar,
|
onPressed: _guardar,
|
||||||
@@ -727,11 +754,6 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
seleccionar(resultado.emisora);
|
seleccionar(resultado.emisora);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _elegirHora() async {
|
|
||||||
final nueva = await showTimePicker(context: context, initialTime: _hora);
|
|
||||||
if (nueva != null) setState(() => _hora = nueva);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _elegirFecha() async {
|
Future<void> _elegirFecha() async {
|
||||||
final ahora = DateTime.now();
|
final ahora = DateTime.now();
|
||||||
final nueva = await showDatePicker(
|
final nueva = await showDatePicker(
|
||||||
@@ -1148,12 +1170,10 @@ class _SectionLabel extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_AssetIcon(icon, size: 34),
|
_AssetIcon(icon, size: 34),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
// WU10: swapped the raw TextTheme lookup for the named type-scale
|
||||||
text,
|
// token (cosmetic only — same weight class, now shared with every
|
||||||
style: Theme.of(
|
// other card/section title in the redesign).
|
||||||
context,
|
Text(text, style: context.pluriType.cardTitle),
|
||||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart' show Share;
|
||||||
import 'package:shimmer/shimmer.dart';
|
import 'package:shimmer/shimmer.dart';
|
||||||
|
|
||||||
import '../estado/estado_ecualizador.dart';
|
import '../estado/estado_ecualizador.dart';
|
||||||
@@ -12,14 +13,29 @@ import '../servicios/servicio_audio.dart';
|
|||||||
import '../servicios/servicio_timer.dart';
|
import '../servicios/servicio_timer.dart';
|
||||||
import '../tema/pluri_animate.dart';
|
import '../tema/pluri_animate.dart';
|
||||||
import '../tema/pluriwave_theme.dart';
|
import '../tema/pluriwave_theme.dart';
|
||||||
|
import '../widgets/ecualizador_widget.dart';
|
||||||
import '../widgets/pluri_glass_surface.dart';
|
import '../widgets/pluri_glass_surface.dart';
|
||||||
import '../widgets/pluri_wave_scaffold.dart';
|
import '../widgets/pluri_premium_widgets.dart';
|
||||||
|
import '../widgets/pluri_push_scaffold.dart';
|
||||||
import '../widgets/visualizador_audio.dart';
|
import '../widgets/visualizador_audio.dart';
|
||||||
|
|
||||||
|
/// WU14: restructured onto [PluriPushScaffold] (design ADR-2) — this screen
|
||||||
|
/// is the ONE documented consumer of `titleOverride` (the centered live/not
|
||||||
|
/// -playing pill) and of a non-default `leadingIcon`
|
||||||
|
/// (`keyboard_arrow_down_rounded`, this screen dismisses down, not back).
|
||||||
|
/// Square art (was circular), a single subtitle line, a quality row, and a
|
||||||
|
/// 4-tile tool tray (EQ propio / Grabar / sleep timer / Compartir) replace
|
||||||
|
/// the old info-chip row, always-expanded recording panel and standalone
|
||||||
|
/// sleep-timer button. The per-station EQ sheet reuses [EcualizadorWidget]
|
||||||
|
/// by its exact runtime type (design ADR-5) — no second editor.
|
||||||
class PantallaReproductor extends StatefulWidget {
|
class PantallaReproductor extends StatefulWidget {
|
||||||
final Emisora emisora;
|
final Emisora emisora;
|
||||||
|
|
||||||
const PantallaReproductor({super.key, required this.emisora});
|
/// Injected for tests (mirrors `pantalla_grabaciones.dart`'s WU15
|
||||||
|
/// `compartir` pattern) — defaults to the real `share_plus` call.
|
||||||
|
final Future<void> Function(String texto)? compartir;
|
||||||
|
|
||||||
|
const PantallaReproductor({super.key, required this.emisora, this.compartir});
|
||||||
|
|
||||||
static Future<void> abrir(BuildContext context, Emisora emisora) {
|
static Future<void> abrir(BuildContext context, Emisora emisora) {
|
||||||
return Navigator.push(
|
return Navigator.push(
|
||||||
@@ -46,18 +62,21 @@ class PantallaReproductor extends StatefulWidget {
|
|||||||
State<PantallaReproductor> createState() => _PantallaReproductorState();
|
State<PantallaReproductor> createState() => _PantallaReproductorState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PantallaReproductorState extends State<PantallaReproductor>
|
class _PantallaReproductorState extends State<PantallaReproductor> {
|
||||||
with SingleTickerProviderStateMixin {
|
late final Future<void> Function(String) _compartir =
|
||||||
late AnimationController _pulseController;
|
widget.compartir ?? (texto) => Share.share(texto);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pulseController = AnimationController(
|
// Bugfix (surfaced by this screen's first-ever test coverage, WU14):
|
||||||
vsync: this,
|
// EstadoRadio.reproducir() calls notifyListeners() synchronously before
|
||||||
duration: const Duration(seconds: 2),
|
// its first await when no recording is active, which previously threw
|
||||||
);
|
// "setState() or markNeedsBuild() called during build" the instant this
|
||||||
_iniciarReproduccion();
|
// screen mounted with a fresh Provider tree (e.g. every widget test that
|
||||||
|
// pumps this screen for the first time). Deferring to the post-frame
|
||||||
|
// callback keeps the exact same effect one frame later, outside build.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _iniciarReproduccion());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _iniciarReproduccion() async {
|
Future<void> _iniciarReproduccion() async {
|
||||||
@@ -67,12 +86,6 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_pulseController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -87,46 +100,29 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
|||||||
(e) => e.uuid == emisoraActiva.uuid,
|
(e) => e.uuid == emisoraActiva.uuid,
|
||||||
);
|
);
|
||||||
|
|
||||||
return PluriWaveScaffold(
|
return PluriPushScaffold(
|
||||||
appBar: AppBar(
|
title: emisoraActiva.nombre,
|
||||||
backgroundColor: Colors.transparent,
|
leadingIcon: Icons.keyboard_arrow_down_rounded,
|
||||||
elevation: 0,
|
titleOverride: StreamBuilder<EstadoReproduccion>(
|
||||||
leading: IconButton(
|
stream: estado.estadoStream,
|
||||||
icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 32),
|
builder: (context, snapshot) {
|
||||||
tooltip: l10n.closeAction,
|
final enVivo = snapshot.data == EstadoReproduccion.reproduciendo;
|
||||||
onPressed: () => Navigator.pop(context),
|
return PluriStatusPill(
|
||||||
),
|
icon:
|
||||||
actions: [
|
enVivo
|
||||||
IconButton(
|
? Icons.podcasts_rounded
|
||||||
icon: Icon(
|
: Icons.pause_circle_outline_rounded,
|
||||||
eq.activo ? Icons.equalizer_rounded : Icons.equalizer_outlined,
|
label: enVivo ? l10n.liveNow : l10n.notPlaying,
|
||||||
color: eq.activo ? tokens.warmCoral : null,
|
accent: enVivo ? tokens.liveGreen : null,
|
||||||
),
|
);
|
||||||
tooltip: eq.activo ? l10n.equalizerDisable : l10n.equalizerEnable,
|
},
|
||||||
onPressed: () => eq.cambiarActivo(!eq.activo),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
esFavorito
|
|
||||||
? Icons.favorite_rounded
|
|
||||||
: Icons.favorite_outline_rounded,
|
|
||||||
color: esFavorito ? theme.colorScheme.error : null,
|
|
||||||
),
|
|
||||||
tooltip:
|
|
||||||
esFavorito
|
|
||||||
? l10n.favoritesRemoveTooltip
|
|
||||||
: l10n.favoritesAddTooltip,
|
|
||||||
onPressed: () async => estado.toggleFavorito(emisoraActiva),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 8),
|
_ArteReproductor(
|
||||||
_WaveHero(
|
|
||||||
emisora: emisoraActiva,
|
emisora: emisoraActiva,
|
||||||
estadoStream: estado.estadoStream,
|
estadoStream: estado.estadoStream,
|
||||||
).pluriScaleIn(
|
).pluriScaleIn(
|
||||||
@@ -145,23 +141,15 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
|||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 150)),
|
).pluriFadeIn(context, delay: const Duration(milliseconds: 150)),
|
||||||
const SizedBox(height: 10),
|
|
||||||
_InfoChips(emisora: emisoraActiva).pluriFadeSlideIn(
|
|
||||||
context,
|
|
||||||
delay: const Duration(milliseconds: 200),
|
|
||||||
beginY: 0.2,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
if (emisoraActiva.codec != null || emisoraActiva.bitrate != null)
|
_SubtituloInfo(
|
||||||
Text(
|
emisora: emisoraActiva,
|
||||||
_codecInfo(context, emisoraActiva),
|
).pluriFadeIn(context, delay: const Duration(milliseconds: 190)),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
const SizedBox(height: 12),
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
|
_FilaCalidad(
|
||||||
),
|
estado: estado,
|
||||||
).pluriFadeIn(
|
emisora: emisoraActiva,
|
||||||
context,
|
).pluriFadeIn(context, delay: const Duration(milliseconds: 230)),
|
||||||
delay: const Duration(milliseconds: 250),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
PluriGlassSurface(
|
PluriGlassSurface(
|
||||||
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
||||||
@@ -177,54 +165,48 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
|||||||
color: tokens.warmCoral,
|
color: tokens.warmCoral,
|
||||||
altura: 46,
|
altura: 46,
|
||||||
),
|
),
|
||||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 280)),
|
).pluriFadeIn(context, delay: const Duration(milliseconds: 270)),
|
||||||
const Spacer(),
|
const SizedBox(height: 22),
|
||||||
_Controles(
|
_Controles(
|
||||||
estado: estado,
|
estado: estado,
|
||||||
emisora: emisoraActiva,
|
emisora: emisoraActiva,
|
||||||
|
esFavorito: esFavorito,
|
||||||
).pluriFadeSlideIn(
|
).pluriFadeSlideIn(
|
||||||
context,
|
context,
|
||||||
delay: const Duration(milliseconds: 300),
|
delay: const Duration(milliseconds: 310),
|
||||||
beginY: 0.3,
|
beginY: 0.3,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 20),
|
||||||
const _GrabacionWidget().pluriFadeIn(
|
_BandejaHerramientas(
|
||||||
context,
|
|
||||||
delay: const Duration(milliseconds: 360),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
_TimerWidget(
|
|
||||||
estado: estado,
|
estado: estado,
|
||||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 400)),
|
eq: eq,
|
||||||
const SizedBox(height: 16),
|
emisora: emisoraActiva,
|
||||||
|
compartir: _compartir,
|
||||||
|
).pluriFadeIn(context, delay: const Duration(milliseconds: 350)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _codecInfo(BuildContext context, Emisora e) {
|
|
||||||
final parts = <String>[];
|
|
||||||
if (e.codec != null) parts.add(e.codec!.toUpperCase());
|
|
||||||
if (e.bitrate != null && e.bitrate! > 0) parts.add('${e.bitrate} kbps');
|
|
||||||
return parts.isEmpty
|
|
||||||
? AppLocalizations.of(context).qualityUnknown
|
|
||||||
: AppLocalizations.of(context).qualityOriginal(parts.join(' · '));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _WaveHero extends StatelessWidget {
|
/// Square art (design proposal WU14 row: "square art" replaces the old
|
||||||
|
/// circular `_WaveHero`). Loading/error overlays and the fallback icon are
|
||||||
|
/// unchanged from the prior circular version — only the clip shape and the
|
||||||
|
/// decorative halo geometry changed from circle to rounded-square.
|
||||||
|
class _ArteReproductor extends StatelessWidget {
|
||||||
final Emisora emisora;
|
final Emisora emisora;
|
||||||
final Stream<EstadoReproduccion> estadoStream;
|
final Stream<EstadoReproduccion> estadoStream;
|
||||||
|
|
||||||
const _WaveHero({required this.emisora, required this.estadoStream});
|
const _ArteReproductor({required this.emisora, required this.estadoStream});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final t = context.pluriTokens;
|
final t = context.pluriTokens;
|
||||||
final size = MediaQuery.of(context).size.width * 0.62;
|
final size = MediaQuery.of(context).size.width * 0.62;
|
||||||
|
final radio = BorderRadius.circular(t.radiusLg);
|
||||||
|
|
||||||
return StreamBuilder<EstadoReproduccion>(
|
return StreamBuilder<EstadoReproduccion>(
|
||||||
stream: estadoStream,
|
stream: estadoStream,
|
||||||
@@ -237,6 +219,7 @@ class _WaveHero extends StatelessWidget {
|
|||||||
final hayError = snapshot.data == EstadoReproduccion.error;
|
final hayError = snapshot.data == EstadoReproduccion.error;
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
|
key: const Key('player-hero-art'),
|
||||||
width: size + 40,
|
width: size + 40,
|
||||||
height: size + 40,
|
height: size + 40,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -246,7 +229,7 @@ class _WaveHero extends StatelessWidget {
|
|||||||
width: size + 34,
|
width: size + 34,
|
||||||
height: size + 34,
|
height: size + 34,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
borderRadius: BorderRadius.circular(t.radiusLg + 16),
|
||||||
gradient: RadialGradient(
|
gradient: RadialGradient(
|
||||||
colors: [
|
colors: [
|
||||||
t.electricMagenta.withValues(
|
t.electricMagenta.withValues(
|
||||||
@@ -261,17 +244,18 @@ class _WaveHero extends StatelessWidget {
|
|||||||
width: size + 12,
|
width: size + 12,
|
||||||
height: size + 12,
|
height: size + 12,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
borderRadius: BorderRadius.circular(t.radiusLg + 4),
|
||||||
border: Border.all(color: t.glassBorder),
|
border: Border.all(color: t.glassBorder),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PluriGlassSurface(
|
PluriGlassSurface(
|
||||||
borderRadius: BorderRadius.circular(size),
|
borderRadius: radio,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
child: ClipOval(
|
child: ClipRRect(
|
||||||
|
borderRadius: radio,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
@@ -335,44 +319,93 @@ class _WaveHero extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InfoChips extends StatelessWidget {
|
/// Single subtitle line (WU14: collapses the old `_InfoChips` `Wrap` of
|
||||||
|
/// separate chips — country/language now join as one line; codec/bitrate
|
||||||
|
/// moved into their own [_FilaCalidad] row below).
|
||||||
|
class _SubtituloInfo extends StatelessWidget {
|
||||||
|
const _SubtituloInfo({required this.emisora});
|
||||||
|
|
||||||
final Emisora emisora;
|
final Emisora emisora;
|
||||||
const _InfoChips({required this.emisora});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final partes = <String>[
|
||||||
final items = <String>[];
|
if (emisora.pais != null && emisora.pais!.isNotEmpty) emisora.pais!,
|
||||||
if (emisora.pais != null) items.add(emisora.pais!);
|
if (emisora.idioma != null && emisora.idioma!.isNotEmpty) emisora.idioma!,
|
||||||
if (emisora.idioma != null) items.add(emisora.idioma!);
|
];
|
||||||
if ((emisora.bitrate ?? 0) > 0) items.add('${emisora.bitrate} kbps');
|
if (partes.isEmpty) return const SizedBox.shrink();
|
||||||
if (emisora.codec != null) items.add(emisora.codec!.toUpperCase());
|
|
||||||
if (items.isEmpty) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
return Wrap(
|
return Text(
|
||||||
spacing: 8,
|
key: const Key('player-subtitle-line'),
|
||||||
runSpacing: 6,
|
partes.join(' · '),
|
||||||
alignment: WrapAlignment.center,
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
children:
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.72),
|
||||||
items
|
),
|
||||||
.map(
|
textAlign: TextAlign.center,
|
||||||
(label) => Chip(
|
maxLines: 1,
|
||||||
label: Text(label),
|
overflow: TextOverflow.ellipsis,
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
backgroundColor: theme.colorScheme.secondaryContainer
|
|
||||||
.withValues(alpha: 0.8),
|
|
||||||
labelStyle: TextStyle(
|
|
||||||
color: theme.colorScheme.onSecondaryContainer,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Quality row (WU14: "quality row + Cambiar action" per the proposal's
|
||||||
|
/// WU14 blast-radius line). **Design decision, not specified by any ADR**
|
||||||
|
/// (WU14 has none): Radio Browser stations are one fixed stream each — this
|
||||||
|
/// app has no per-station alternate-quality capability to invoke. Rather
|
||||||
|
/// than a dead "Cambiar" button or an invented picker, it reconnects the
|
||||||
|
/// current stream (the SAME `estado.reproducir(emisora)` call the error
|
||||||
|
/// state's existing "Retry" button already uses) — a real, testable,
|
||||||
|
/// zero-new-capability action, matching this branch's established
|
||||||
|
/// "don't invent a capability absent from the domain" discipline (WU5's
|
||||||
|
/// per-station artwork, WU9's dashed border).
|
||||||
|
class _FilaCalidad extends StatelessWidget {
|
||||||
|
const _FilaCalidad({required this.estado, required this.emisora});
|
||||||
|
|
||||||
|
final EstadoRadio estado;
|
||||||
|
final Emisora emisora;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final tokens = context.pluriTokens;
|
||||||
|
|
||||||
|
return PluriGlassSurface(
|
||||||
|
key: const Key('player-quality-row'),
|
||||||
|
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.hd_rounded, size: 20, color: tokens.liveGreen),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_codecInfo(context, emisora),
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
key: const Key('player-quality-change-action'),
|
||||||
|
onPressed: () => estado.reproducir(emisora),
|
||||||
|
child: Text(l10n.playerQualityChangeAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _codecInfo(BuildContext context, Emisora e) {
|
||||||
|
final parts = <String>[];
|
||||||
|
if (e.codec != null) parts.add(e.codec!.toUpperCase());
|
||||||
|
if (e.bitrate != null && e.bitrate! > 0) parts.add('${e.bitrate} kbps');
|
||||||
|
return parts.isEmpty
|
||||||
|
? AppLocalizations.of(context).qualityUnknown
|
||||||
|
: AppLocalizations.of(context).qualityOriginal(parts.join(' · '));
|
||||||
|
}
|
||||||
|
|
||||||
class _GrabacionWidget extends StatelessWidget {
|
class _GrabacionWidget extends StatelessWidget {
|
||||||
// Recording state lives in EstadoGrabacion (S4-R2); EstadoRadio no longer
|
// Recording state lives in EstadoGrabacion (S4-R2); EstadoRadio no longer
|
||||||
// notifies on recording progress, so this widget watches the new notifier.
|
// notifies on recording progress, so this widget watches the new notifier.
|
||||||
@@ -656,11 +689,20 @@ const _opciones = [
|
|||||||
_OpcionGrabacion(Duration(minutes: 30)),
|
_OpcionGrabacion(Duration(minutes: 30)),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Transport row (WU14: favorite moved here from the AppBar; the old
|
||||||
|
/// standalone live/not-playing dot is gone — the AppBar's status pill
|
||||||
|
/// already covers that signal, so "favorite / stop / play-pause" is the
|
||||||
|
/// full set — matching the proposal's own "3 controles" characterisation).
|
||||||
class _Controles extends StatelessWidget {
|
class _Controles extends StatelessWidget {
|
||||||
final EstadoRadio estado;
|
final EstadoRadio estado;
|
||||||
final Emisora emisora;
|
final Emisora emisora;
|
||||||
|
final bool esFavorito;
|
||||||
|
|
||||||
const _Controles({required this.estado, required this.emisora});
|
const _Controles({
|
||||||
|
required this.estado,
|
||||||
|
required this.emisora,
|
||||||
|
required this.esFavorito,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -709,8 +751,32 @@ class _Controles extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
|
Semantics(
|
||||||
|
button: true,
|
||||||
|
label:
|
||||||
|
esFavorito
|
||||||
|
? l10n.favoritesRemoveTooltip
|
||||||
|
: l10n.favoritesAddTooltip,
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
esFavorito
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_outline_rounded,
|
||||||
|
),
|
||||||
|
iconSize: 28,
|
||||||
|
color:
|
||||||
|
esFavorito
|
||||||
|
? theme.colorScheme.error
|
||||||
|
: theme.colorScheme.onSurface.withValues(alpha: 0.78),
|
||||||
|
tooltip:
|
||||||
|
esFavorito
|
||||||
|
? l10n.favoritesRemoveTooltip
|
||||||
|
: l10n.favoritesAddTooltip,
|
||||||
|
onPressed: () => estado.toggleFavorito(emisora),
|
||||||
|
),
|
||||||
|
),
|
||||||
Semantics(
|
Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: l10n.stopPlaybackTooltip,
|
label: l10n.stopPlaybackTooltip,
|
||||||
@@ -726,7 +792,6 @@ class _Controles extends StatelessWidget {
|
|||||||
onPressed: cargando ? null : estado.detenerReproduccion,
|
onPressed: cargando ? null : estado.detenerReproduccion,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
|
||||||
AnimatedContainer(
|
AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
width: 72,
|
width: 72,
|
||||||
@@ -788,18 +853,6 @@ class _Controles extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
|
||||||
Semantics(
|
|
||||||
label: reproduciendo ? l10n.liveNow : l10n.notPlaying,
|
|
||||||
child: Icon(
|
|
||||||
Icons.fiber_manual_record_rounded,
|
|
||||||
size: 32,
|
|
||||||
color:
|
|
||||||
reproduciendo
|
|
||||||
? theme.colorScheme.error
|
|
||||||
: theme.colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -808,99 +861,259 @@ class _Controles extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TimerWidget extends StatelessWidget {
|
/// One tile of the 4-tile tool tray (WU14: EQ propio / Grabar / sleep timer
|
||||||
final EstadoRadio estado;
|
/// / Compartir), each opening its own bottom sheet (Compartir invokes
|
||||||
const _TimerWidget({required this.estado});
|
/// directly instead — there is no sheet content for it).
|
||||||
|
class _TileHerramienta extends StatelessWidget {
|
||||||
|
const _TileHerramienta({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
this.iconColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final Color? iconColor;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final tokens = context.pluriTokens;
|
||||||
|
|
||||||
if (!estado.timer.activo) {
|
return Material(
|
||||||
return TextButton.icon(
|
type: MaterialType.transparency,
|
||||||
icon: const Icon(Icons.bedtime_outlined, size: 18),
|
child: InkWell(
|
||||||
label: Text(AppLocalizations.of(context).sleepTimer),
|
borderRadius: BorderRadius.circular(tokens.radiusSm),
|
||||||
onPressed: () => _mostrarTimerDialog(context),
|
onTap: onTap,
|
||||||
);
|
child: Container(
|
||||||
}
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
return StreamBuilder<Duration>(
|
color: tokens.listSurface.withValues(alpha: 0.55),
|
||||||
stream: estado.timer.tiempoRestanteStream,
|
borderRadius: BorderRadius.circular(tokens.radiusSm),
|
||||||
builder: (context, snap) {
|
|
||||||
final t = snap.data ?? Duration.zero;
|
|
||||||
final m = t.inMinutes.remainder(60).toString().padLeft(2, '0');
|
|
||||||
final s = t.inSeconds.remainder(60).toString().padLeft(2, '0');
|
|
||||||
final label =
|
|
||||||
t.inHours > 0
|
|
||||||
? AppLocalizations.of(
|
|
||||||
context,
|
|
||||||
).durationHoursMinutesSeconds(t.inHours, m, s)
|
|
||||||
: AppLocalizations.of(context).durationMinutesSeconds(m, s);
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.bedtime_rounded,
|
|
||||||
size: 16,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(label, style: theme.textTheme.bodyMedium),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => estado.cancelarTimer(),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
),
|
|
||||||
child: Text(AppLocalizations.of(context).cancelAction),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _mostrarTimerDialog(BuildContext context) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder:
|
|
||||||
(ctx) => SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(ctx).sleepTimer,
|
|
||||||
style: Theme.of(ctx).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
children:
|
|
||||||
opcionesTimer
|
|
||||||
.map(
|
|
||||||
(min) => ActionChip(
|
|
||||||
label: Text(
|
|
||||||
AppLocalizations.of(
|
|
||||||
ctx,
|
|
||||||
).durationMinutesOnly(min),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
estado.iniciarTimer(min);
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 23, color: iconColor ?? tokens.electricMagenta),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _BandejaHerramientas extends StatelessWidget {
|
||||||
|
const _BandejaHerramientas({
|
||||||
|
required this.estado,
|
||||||
|
required this.eq,
|
||||||
|
required this.emisora,
|
||||||
|
required this.compartir,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EstadoRadio estado;
|
||||||
|
final EstadoEcualizador eq;
|
||||||
|
final Emisora emisora;
|
||||||
|
final Future<void> Function(String) compartir;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _TileHerramienta(
|
||||||
|
key: const Key('player-tool-eq'),
|
||||||
|
icon: Icons.equalizer_rounded,
|
||||||
|
label: l10n.playerToolEqLabel,
|
||||||
|
onTap: () => _mostrarHojaEq(context, eq, emisora),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Consumer<EstadoGrabacion>(
|
||||||
|
builder: (context, grabacion, _) {
|
||||||
|
final activa = grabacion.estado.activa;
|
||||||
|
return _TileHerramienta(
|
||||||
|
key: const Key('player-tool-record'),
|
||||||
|
icon:
|
||||||
|
activa
|
||||||
|
? Icons.fiber_manual_record_rounded
|
||||||
|
: Icons.mic_rounded,
|
||||||
|
iconColor: activa ? Theme.of(context).colorScheme.error : null,
|
||||||
|
label: activa ? l10n.recordingActiveTitle : l10n.recordAction,
|
||||||
|
onTap: () => _mostrarHojaGrabacion(context),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child:
|
||||||
|
!estado.timer.activo
|
||||||
|
? _TileHerramienta(
|
||||||
|
key: const Key('player-tool-sleep'),
|
||||||
|
icon: Icons.bedtime_outlined,
|
||||||
|
label: l10n.sleepTimer,
|
||||||
|
onTap: () => _mostrarHojaTimer(context, estado),
|
||||||
|
)
|
||||||
|
: StreamBuilder<Duration>(
|
||||||
|
stream: estado.timer.tiempoRestanteStream,
|
||||||
|
builder: (context, snap) {
|
||||||
|
final t = snap.data ?? estado.timer.tiempoRestante;
|
||||||
|
return _TileHerramienta(
|
||||||
|
key: const Key('player-tool-sleep'),
|
||||||
|
icon: Icons.bedtime_rounded,
|
||||||
|
label: _formatearTiempoRestante(context, t),
|
||||||
|
onTap: () => _mostrarHojaTimer(context, estado),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _TileHerramienta(
|
||||||
|
key: const Key('player-tool-share'),
|
||||||
|
icon: Icons.share_rounded,
|
||||||
|
label: l10n.recordingActionShare,
|
||||||
|
onTap: () => compartir('${emisora.nombre}\n${emisora.url}'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatearTiempoRestante(BuildContext context, Duration t) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final m = t.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||||
|
final s = t.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
|
return t.inHours > 0
|
||||||
|
? l10n.durationHoursMinutesSeconds(t.inHours, m, s)
|
||||||
|
: l10n.durationMinutesSeconds(m, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the per-station EQ sheet. Reuses [EcualizadorWidget] by its exact
|
||||||
|
/// runtime type (design ADR-5, spec `eq-custom-presets` "Per-Station EQ
|
||||||
|
/// Entry Point From the Player") — bound via the SAME per-station
|
||||||
|
/// persistence path Settings' Ecualizador screen (WU13) uses, just against
|
||||||
|
/// this station's uuid instead of `presetPrincipal`. Deliberately just the
|
||||||
|
/// 5 sliders (no preset-chip row) — the spec scenario names "5 sliders",
|
||||||
|
/// not a full preset picker for a single-station override.
|
||||||
|
Future<void> _mostrarHojaEq(
|
||||||
|
BuildContext context,
|
||||||
|
EstadoEcualizador eq,
|
||||||
|
Emisora emisora,
|
||||||
|
) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder:
|
||||||
|
(ctx) => Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
MediaQuery.viewInsetsOf(ctx).bottom + 24,
|
||||||
|
),
|
||||||
|
child: EcualizadorWidget(
|
||||||
|
key: const Key('player-eq-sheet-editor'),
|
||||||
|
preset: eq.presetParaEmisora(emisora.uuid),
|
||||||
|
habilitado: eq.activo,
|
||||||
|
onCambio: (p) => eq.guardarPresetPorEmisora(emisora.uuid, p),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the recording sheet (WU14: relocates the old always-expanded
|
||||||
|
/// `_GrabacionWidget` card behind the "Grabar" tool-tray tile — its OWN
|
||||||
|
/// content/logic is unchanged, only its call site moves).
|
||||||
|
Future<void> _mostrarHojaGrabacion(BuildContext context) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder:
|
||||||
|
(ctx) => Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
MediaQuery.viewInsetsOf(ctx).bottom + 24,
|
||||||
|
),
|
||||||
|
child: const _GrabacionWidget(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the sleep-timer sheet (WU14: relocates the old standalone
|
||||||
|
/// `_TimerWidget` button/inline-countdown behind the "Sleep timer"
|
||||||
|
/// tool-tray tile). Adds a "Cancel timer" option at the top when a timer is
|
||||||
|
/// already active — that capability existed inline before (the old
|
||||||
|
/// countdown row's own Cancel button) and is preserved, not dropped.
|
||||||
|
Future<void> _mostrarHojaTimer(BuildContext context, EstadoRadio estado) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(ctx) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(ctx).sleepTimer,
|
||||||
|
style: Theme.of(ctx).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
if (estado.timer.activo)
|
||||||
|
ActionChip(
|
||||||
|
key: const Key('player-sleep-cancel-action'),
|
||||||
|
avatar: const Icon(Icons.close_rounded, size: 18),
|
||||||
|
label: Text(l10n.cancelAction),
|
||||||
|
onPressed: () {
|
||||||
|
estado.cancelarTimer();
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
for (final min in opcionesTimer)
|
||||||
|
ActionChip(
|
||||||
|
label: Text(
|
||||||
|
AppLocalizations.of(ctx).durationMinutesOnly(min),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
estado.iniciarTimer(min);
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../modelos/preset_ecualizador.dart';
|
||||||
|
import 'persistencia_tolerante.dart';
|
||||||
|
|
||||||
|
/// Persistence for USER-NAMED custom EQ presets (design ADR-5 hazard box,
|
||||||
|
/// `eq-custom-presets` spec — "Custom Preset Save").
|
||||||
|
///
|
||||||
|
/// Deliberately its OWN file and OWN SharedPreferences key
|
||||||
|
/// (`eq_custom_presets_v1`), never folded into [ServicioEcualizador]: that
|
||||||
|
/// file has an empty-`git diff` success criterion for this change, so
|
||||||
|
/// nothing custom-preset-related may be added there. A custom preset is
|
||||||
|
/// just a [PresetEcualizador] with a user-supplied `nombre` — the model
|
||||||
|
/// itself needs no change (`toJson`/`desdeJson` already exist).
|
||||||
|
class ServicioPresetsPersonalizados {
|
||||||
|
ServicioPresetsPersonalizados({SharedPreferences? prefs}) : _prefs = prefs;
|
||||||
|
|
||||||
|
static const _keyPresetsPersonalizados = 'eq_custom_presets_v1';
|
||||||
|
|
||||||
|
final SharedPreferences? _prefs;
|
||||||
|
|
||||||
|
Future<SharedPreferences> _resolverPrefs() async =>
|
||||||
|
_prefs ?? SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
/// Returns every saved custom preset, in save order.
|
||||||
|
Future<List<PresetEcualizador>> listar() async {
|
||||||
|
final prefs = await _resolverPrefs();
|
||||||
|
return _leer(prefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves [preset] under its own name. A preset already saved under the
|
||||||
|
/// same name is replaced (last write wins) rather than duplicated.
|
||||||
|
Future<void> guardar(PresetEcualizador preset) async {
|
||||||
|
final prefs = await _resolverPrefs();
|
||||||
|
final actuales =
|
||||||
|
_leer(prefs)
|
||||||
|
..removeWhere((p) => p.nombre == preset.nombre)
|
||||||
|
..add(preset);
|
||||||
|
await _guardarTodos(prefs, actuales);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the custom preset named [nombre], if present. A safe no-op
|
||||||
|
/// when no preset with that name exists.
|
||||||
|
Future<void> eliminar(String nombre) async {
|
||||||
|
final prefs = await _resolverPrefs();
|
||||||
|
final actuales = _leer(prefs)..removeWhere((p) => p.nombre == nombre);
|
||||||
|
await _guardarTodos(prefs, actuales);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the persisted list with per-entry tolerance
|
||||||
|
/// (persistence-resilience D1/D6, same shared helper `ServicioEcualizador`
|
||||||
|
/// uses): an entry that fails to parse is skipped and logged, its
|
||||||
|
/// siblings survive. A top-level decode failure degrades to an empty
|
||||||
|
/// list (also logged) — custom presets are explicit-only user writes and
|
||||||
|
/// trivially re-creatable, so there is no flag/quarantine here, matching
|
||||||
|
/// `ServicioEcualizador`'s own documented D6 asymmetry vs. Alarms/Stations.
|
||||||
|
List<PresetEcualizador> _leer(SharedPreferences prefs) {
|
||||||
|
final raw = prefs.getString(_keyPresetsPersonalizados);
|
||||||
|
if (raw == null || raw.isEmpty) return [];
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(raw) as List<dynamic>;
|
||||||
|
final resultado = parseListaTolerante<PresetEcualizador>(
|
||||||
|
data,
|
||||||
|
(entrada) => PresetEcualizador.desdeJson(entrada),
|
||||||
|
subsistema: 'presets_personalizados',
|
||||||
|
coleccion: _keyPresetsPersonalizados,
|
||||||
|
);
|
||||||
|
return resultado.validas;
|
||||||
|
} catch (e) {
|
||||||
|
registrarSaltoPersistencia(
|
||||||
|
subsistema: 'presets_personalizados',
|
||||||
|
detalle: _keyPresetsPersonalizados,
|
||||||
|
razon: e.toString(),
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _guardarTodos(
|
||||||
|
SharedPreferences prefs,
|
||||||
|
List<PresetEcualizador> presets,
|
||||||
|
) async {
|
||||||
|
final serializado = presets.map((p) => p.toJson()).toList();
|
||||||
|
await prefs.setString(_keyPresetsPersonalizados, jsonEncode(serializado));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,16 @@ class EcualizadorWidget extends StatefulWidget {
|
|||||||
final PresetEcualizador preset;
|
final PresetEcualizador preset;
|
||||||
final void Function(PresetEcualizador) onCambio;
|
final void Function(PresetEcualizador) onCambio;
|
||||||
|
|
||||||
|
/// Design ADR-5: greys and disables every slider when the equalizer
|
||||||
|
/// itself is off, instead of leaving fully-interactive controls that
|
||||||
|
/// silently do nothing.
|
||||||
|
final bool habilitado;
|
||||||
|
|
||||||
const EcualizadorWidget({
|
const EcualizadorWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.preset,
|
required this.preset,
|
||||||
required this.onCambio,
|
required this.onCambio,
|
||||||
|
this.habilitado = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -50,91 +56,92 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
|
|||||||
final tokens = context.pluriTokens;
|
final tokens = context.pluriTokens;
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
// Design ADR-5: the title + preset Chip row that used to live here is
|
||||||
|
// gone — the pushed screen's own 56px header carries the title now, and
|
||||||
|
// the preset chip ROW (a different, still-reusable widget,
|
||||||
|
// [PresetsEcualizadorWidget] below) is rendered by the caller alongside
|
||||||
|
// this widget instead of being duplicated inside it.
|
||||||
return PluriGlassSurface(
|
return PluriGlassSurface(
|
||||||
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
for (int i = 0; i < 5; i++)
|
||||||
children: [
|
Expanded(
|
||||||
Text(
|
child: AnimatedOpacity(
|
||||||
l10n.equalizerTitle,
|
duration: const Duration(milliseconds: 150),
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
opacity: widget.habilitado ? 1.0 : 0.4,
|
||||||
fontWeight: FontWeight.w700,
|
child: Card(
|
||||||
),
|
color: tokens.listSurface.withValues(alpha: 0.6),
|
||||||
),
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
const Spacer(),
|
shape: RoundedRectangleBorder(
|
||||||
Chip(
|
borderRadius: BorderRadius.circular(tokens.radiusSm),
|
||||||
label: Text(
|
),
|
||||||
_nombrePreset(l10n, widget.preset.nombre),
|
child: Padding(
|
||||||
style: theme.textTheme.labelMedium,
|
padding: const EdgeInsets.symmetric(
|
||||||
),
|
vertical: 10,
|
||||||
backgroundColor: theme.colorScheme.secondaryContainer
|
horizontal: 4,
|
||||||
.withValues(alpha: 0.75),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
for (int i = 0; i < 5; i++)
|
|
||||||
Expanded(
|
|
||||||
child: Card(
|
|
||||||
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
|
||||||
alpha: 0.35,
|
|
||||||
),
|
),
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
child: Column(
|
||||||
shape: RoundedRectangleBorder(
|
children: [
|
||||||
borderRadius: BorderRadius.circular(14),
|
SizedBox(
|
||||||
),
|
height: 152,
|
||||||
child: Padding(
|
child: Semantics(
|
||||||
padding: const EdgeInsets.symmetric(
|
slider: true,
|
||||||
vertical: 8,
|
enabled: widget.habilitado,
|
||||||
horizontal: 4,
|
label: l10n.equalizerBandLabel(_etiquetas[i]),
|
||||||
),
|
value: l10n.equalizerBandValue(
|
||||||
child: Column(
|
_bandas[i].toStringAsFixed(1),
|
||||||
children: [
|
),
|
||||||
SizedBox(
|
child: RotatedBox(
|
||||||
height: 152,
|
quarterTurns: 3,
|
||||||
child: Semantics(
|
child: SliderTheme(
|
||||||
slider: true,
|
data: SliderTheme.of(context).copyWith(
|
||||||
label: l10n.equalizerBandLabel(_etiquetas[i]),
|
trackHeight: 5,
|
||||||
value: l10n.equalizerBandValue(
|
activeTrackColor: tokens.liveGreen,
|
||||||
_bandas[i].toStringAsFixed(1),
|
thumbColor: tokens.liveGreen,
|
||||||
),
|
inactiveTrackColor:
|
||||||
child: RotatedBox(
|
theme.colorScheme.surfaceContainerHighest,
|
||||||
quarterTurns: 3,
|
overlayColor: tokens.liveGreen.withValues(
|
||||||
|
alpha: 0.15,
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Slider(
|
child: Slider(
|
||||||
value: _bandas[i],
|
value: _bandas[i],
|
||||||
min: -12.0,
|
min: -12.0,
|
||||||
max: 12.0,
|
max: 12.0,
|
||||||
divisions: 24,
|
divisions: 24,
|
||||||
onChanged: (v) => _actualizarBanda(i, v),
|
onChanged:
|
||||||
|
widget.habilitado
|
||||||
|
? (v) => _actualizarBanda(i, v)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
),
|
||||||
'${_bandas[i].toStringAsFixed(1)}dB',
|
Text(
|
||||||
style: theme.textTheme.labelSmall,
|
'${_bandas[i].toStringAsFixed(1)}dB',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: tokens.liveGreen,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
Text(
|
),
|
||||||
_etiquetas[i],
|
Text(
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
_etiquetas[i],
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
),
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
],
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -158,21 +165,31 @@ class PresetsEcualizadorWidget extends StatelessWidget {
|
|||||||
final PresetEcualizador presetActual;
|
final PresetEcualizador presetActual;
|
||||||
final void Function(PresetEcualizador) onSeleccionar;
|
final void Function(PresetEcualizador) onSeleccionar;
|
||||||
|
|
||||||
|
/// User-saved custom presets (WU13, `eq-custom-presets` spec), appended
|
||||||
|
/// after the 6 fixed ones. Additive-only parameter — defaults to empty so
|
||||||
|
/// this stays the same widget, not a new one (design ADR-5: "stays
|
||||||
|
/// as-is" means no restyle, not that it can never gain new data to show).
|
||||||
|
/// `_nombrePreset`'s default case already falls through to the raw name,
|
||||||
|
/// so a custom preset's chip label needs no special-casing here.
|
||||||
|
final List<PresetEcualizador> personalizados;
|
||||||
|
|
||||||
const PresetsEcualizadorWidget({
|
const PresetsEcualizadorWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.presetActual,
|
required this.presetActual,
|
||||||
required this.onSeleccionar,
|
required this.onSeleccionar,
|
||||||
|
this.personalizados = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final todos = [...PresetEcualizador.presets, ...personalizados];
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 6,
|
runSpacing: 6,
|
||||||
children:
|
children:
|
||||||
PresetEcualizador.presets.map((p) {
|
todos.map((p) {
|
||||||
final selected = p.nombre == presetActual.nombre;
|
final selected = p.nombre == presetActual.nombre;
|
||||||
return ChoiceChip(
|
return ChoiceChip(
|
||||||
label: Text(_nombrePreset(l10n, p.nombre)),
|
label: Text(_nombrePreset(l10n, p.nombre)),
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../l10n/gen/app_localizations.dart';
|
||||||
|
import '../tema/pluriwave_theme.dart';
|
||||||
|
|
||||||
|
/// Giant inline HH:MM editor (WU10, `native-alarms` delta — Alarm Editor
|
||||||
|
/// Preserves Date, Fallback Station, and Sound Fields): replaces the native
|
||||||
|
/// `showTimePicker` dialog inside `_EditorAlarmaSheet`. Standalone and
|
||||||
|
/// independent of the sheet — it only exposes `value`/`onChanged`, so it can
|
||||||
|
/// be unit-tested (and reused) with no alarm/editor state at all.
|
||||||
|
///
|
||||||
|
/// Each segment (hour, minute) supports two independent adjustment paths:
|
||||||
|
/// - Tap: increments that segment by one step, wrapping (`23:59` + 1 minute
|
||||||
|
/// wraps to `00:00`, matching a real clock's minute rollover).
|
||||||
|
/// - Vertical drag: continuous bidirectional adjustment — up increases, down
|
||||||
|
/// decreases — for users who want to scrub several steps at once.
|
||||||
|
///
|
||||||
|
/// Screen readers get BOTH directions regardless of the touch affordance:
|
||||||
|
/// each segment exposes `Semantics.onIncrease`/`onDecrease` (the same
|
||||||
|
/// adjustable-control pattern `Slider` uses internally), so a drag gesture is
|
||||||
|
/// never required for accessible use.
|
||||||
|
class EditorHoraInline extends StatefulWidget {
|
||||||
|
const EditorHoraInline({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TimeOfDay value;
|
||||||
|
final ValueChanged<TimeOfDay> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EditorHoraInline> createState() => _EditorHoraInlineState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditorHoraInlineState extends State<EditorHoraInline> {
|
||||||
|
/// Logical pixels of accumulated vertical drag per one-unit step. Chosen
|
||||||
|
/// for a comfortable scrub distance — not derived from any measured
|
||||||
|
/// constant, this widget has no other consumer to stay in sync with.
|
||||||
|
static const double _pixelesPorPaso = 24;
|
||||||
|
|
||||||
|
double _arrastreHora = 0;
|
||||||
|
double _arrastreMinuto = 0;
|
||||||
|
|
||||||
|
/// Pure preview: the hour after applying [delta], wrapping 23→0 / 0→23.
|
||||||
|
/// Shared by the actual mutation and by the `Semantics`
|
||||||
|
/// increasedValue/decreasedValue text (Flutter requires both whenever
|
||||||
|
/// `onIncrease`/`onDecrease` are set).
|
||||||
|
int _horaConDelta(int delta) {
|
||||||
|
final horas = (widget.value.hour + delta) % 24;
|
||||||
|
return horas < 0 ? horas + 24 : horas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure preview: the minute after applying [delta] to the whole HH:MM,
|
||||||
|
/// wrapping at the day boundary (`23:59` + 1 minute → `00:00`).
|
||||||
|
TimeOfDay _horaCompletaConDeltaMinuto(int delta) {
|
||||||
|
final totalMinutos = widget.value.hour * 60 + widget.value.minute + delta;
|
||||||
|
final normalizado = totalMinutos % (24 * 60);
|
||||||
|
final positivo = normalizado < 0 ? normalizado + 24 * 60 : normalizado;
|
||||||
|
return TimeOfDay(hour: positivo ~/ 60, minute: positivo % 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ajustarHora(int delta) {
|
||||||
|
if (delta == 0) return;
|
||||||
|
widget.onChanged(
|
||||||
|
TimeOfDay(hour: _horaConDelta(delta), minute: widget.value.minute),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ajustarMinuto(int delta) {
|
||||||
|
if (delta == 0) return;
|
||||||
|
widget.onChanged(_horaCompletaConDeltaMinuto(delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onArrastreHora(DragUpdateDetails details) {
|
||||||
|
// Screen-space dy grows downward, so an upward drag (negative dy) must
|
||||||
|
// increase the value: subtract, don't add.
|
||||||
|
_arrastreHora -= details.delta.dy;
|
||||||
|
while (_arrastreHora >= _pixelesPorPaso) {
|
||||||
|
_arrastreHora -= _pixelesPorPaso;
|
||||||
|
_ajustarHora(1);
|
||||||
|
}
|
||||||
|
while (_arrastreHora <= -_pixelesPorPaso) {
|
||||||
|
_arrastreHora += _pixelesPorPaso;
|
||||||
|
_ajustarHora(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onArrastreMinuto(DragUpdateDetails details) {
|
||||||
|
_arrastreMinuto -= details.delta.dy;
|
||||||
|
while (_arrastreMinuto >= _pixelesPorPaso) {
|
||||||
|
_arrastreMinuto -= _pixelesPorPaso;
|
||||||
|
_ajustarMinuto(1);
|
||||||
|
}
|
||||||
|
while (_arrastreMinuto <= -_pixelesPorPaso) {
|
||||||
|
_arrastreMinuto += _pixelesPorPaso;
|
||||||
|
_ajustarMinuto(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final type = context.pluriType;
|
||||||
|
final horaTexto = widget.value.hour.toString().padLeft(2, '0');
|
||||||
|
final minutoTexto = widget.value.minute.toString().padLeft(2, '0');
|
||||||
|
|
||||||
|
return FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_Segmento(
|
||||||
|
key: const ValueKey('editor-hora-inline-hora'),
|
||||||
|
texto: horaTexto,
|
||||||
|
semanticLabel: l10n.alarmInlineHourLabel,
|
||||||
|
incrementado: _horaConDelta(1).toString().padLeft(2, '0'),
|
||||||
|
decrementado: _horaConDelta(-1).toString().padLeft(2, '0'),
|
||||||
|
style: type.heroTime,
|
||||||
|
onTap: () => _ajustarHora(1),
|
||||||
|
onIncrease: () => _ajustarHora(1),
|
||||||
|
onDecrease: () => _ajustarHora(-1),
|
||||||
|
onDragUpdate: _onArrastreHora,
|
||||||
|
),
|
||||||
|
Text(':', style: type.heroTime),
|
||||||
|
_Segmento(
|
||||||
|
key: const ValueKey('editor-hora-inline-minuto'),
|
||||||
|
texto: minutoTexto,
|
||||||
|
semanticLabel: l10n.alarmInlineMinuteLabel,
|
||||||
|
incrementado: _horaCompletaConDeltaMinuto(
|
||||||
|
1,
|
||||||
|
).minute.toString().padLeft(2, '0'),
|
||||||
|
decrementado: _horaCompletaConDeltaMinuto(
|
||||||
|
-1,
|
||||||
|
).minute.toString().padLeft(2, '0'),
|
||||||
|
style: type.heroTime,
|
||||||
|
onTap: () => _ajustarMinuto(1),
|
||||||
|
onIncrease: () => _ajustarMinuto(1),
|
||||||
|
onDecrease: () => _ajustarMinuto(-1),
|
||||||
|
onDragUpdate: _onArrastreMinuto,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Segmento extends StatelessWidget {
|
||||||
|
const _Segmento({
|
||||||
|
super.key,
|
||||||
|
required this.texto,
|
||||||
|
required this.semanticLabel,
|
||||||
|
required this.incrementado,
|
||||||
|
required this.decrementado,
|
||||||
|
required this.style,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onIncrease,
|
||||||
|
required this.onDecrease,
|
||||||
|
required this.onDragUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String texto;
|
||||||
|
final String semanticLabel;
|
||||||
|
|
||||||
|
/// Text `Semantics.value` becomes after `onIncrease`/`onDecrease` fires.
|
||||||
|
/// Flutter requires both whenever a node exposes increase/decrease
|
||||||
|
/// actions alongside a `value` (see `SemanticsNode.updateWith`'s
|
||||||
|
/// `(value == '') == (increasedValue == '')` assertion).
|
||||||
|
final String incrementado;
|
||||||
|
final String decrementado;
|
||||||
|
final TextStyle? style;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onIncrease;
|
||||||
|
final VoidCallback onDecrease;
|
||||||
|
final GestureDragUpdateCallback onDragUpdate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// `excludeSemantics: true` + an explicit `onTap` here: without it, the
|
||||||
|
// GestureDetector's OWN semantics contribution merges upward and (a)
|
||||||
|
// duplicates the digits into `label` (via its child Text's implicit
|
||||||
|
// semantics) and (b) auto-exposes scrollUp/scrollDown (Flutter's default
|
||||||
|
// accessibility mapping for a registered vertical-drag recognizer) —
|
||||||
|
// neither of which this widget wants. Declaring every action explicitly
|
||||||
|
// on this one node keeps the exposed contract exactly label/value/
|
||||||
|
// increasedValue/decreasedValue/tap/increase/decrease, nothing more.
|
||||||
|
return Semantics(
|
||||||
|
label: semanticLabel,
|
||||||
|
value: texto,
|
||||||
|
increasedValue: incrementado,
|
||||||
|
decreasedValue: decrementado,
|
||||||
|
onTap: onTap,
|
||||||
|
onIncrease: onIncrease,
|
||||||
|
onDecrease: onDecrease,
|
||||||
|
excludeSemantics: true,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: onTap,
|
||||||
|
onVerticalDragUpdate: onDragUpdate,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
child: Text(texto, style: style),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,8 +51,8 @@
|
|||||||
| 9 | `feat(vacaciones): add vacation range manager screen` | 8 | 350-450 | Medium | Monitor |
|
| 9 | `feat(vacaciones): add vacation range manager screen` | 8 | 350-450 | Medium | Monitor |
|
||||||
| 10 | `feat(alarmas): rewrite alarm editor with inline time widget` | 8 | 500-650 | High | **Yes — indivisible new widget**‡ |
|
| 10 | `feat(alarmas): rewrite alarm editor with inline time widget` | 8 | 500-650 | High | **Yes — indivisible new widget**‡ |
|
||||||
| 11 | `feat(alarma-sonando): restyle ringing screen, drop live countdown label` | 1 | 200-300 | Low (safety-critical review attention: High) | No |
|
| 11 | `feat(alarma-sonando): restyle ringing screen, drop live countdown label` | 1 | 200-300 | Low (safety-critical review attention: High) | No |
|
||||||
| 13 | `feat(eq): restyle equalizer screen and add custom presets` | 3a | 400-550 | Medium-High | Monitor |
|
| 13 | `feat(eq): restyle equalizer screen and add custom presets` | 3a | ~~400-550~~ → **REALIZED: 1,871** (1,793+ / 78-, 25 files) | Medium-High | **Yes — retroactive, see WU13 section** |
|
||||||
| 14 | `feat(reproductor): restructure full player with tool-tray and EQ sheet` | 13 | 450-600 | Medium-High | Monitor |
|
| 14 | `feat(reproductor): restructure full player with tool-tray and EQ sheet` | 13 | ~~450-600~~ → **REALIZED: 1,335** (1,114+ / 221-, 19 files) | Medium-High | **Yes — retroactive, see WU14 section** |
|
||||||
| 15 | `feat(grabaciones): add recordings library screen` | 3b | ~~300-400~~ → **REALIZED: 1,767** (1,767+ / 0-, 22 files) | Medium | Monitor§ |
|
| 15 | `feat(grabaciones): add recordings library screen` | 3b | ~~300-400~~ → **REALIZED: 1,767** (1,767+ / 0-, 22 files) | Medium | Monitor§ |
|
||||||
| 15b | `fix(grabaciones): wire the recordings library into Settings navigation` | 15 | 60-100 | Low | No |
|
| 15b | `fix(grabaciones): wire the recordings library into Settings navigation` | 15 | 60-100 | Low | No |
|
||||||
| 16 | `feat(connectivity): restyle offline and reconnect banners` | 1 | 150-250 | Low | No |
|
| 16 | `feat(connectivity): restyle offline and reconnect banners` | 1 | 150-250 | Low | No |
|
||||||
@@ -612,22 +612,60 @@ splitting further would leave either dead query methods with no UI consumer or a
|
|||||||
**New tests**: `test/widgets/editor_hora_inline_test.dart` (standalone)
|
**New tests**: `test/widgets/editor_hora_inline_test.dart` (standalone)
|
||||||
**Modified tests**: `pantalla_alarmas_fecha_test.dart`
|
**Modified tests**: `pantalla_alarmas_fecha_test.dart`
|
||||||
|
|
||||||
- [ ] 10.1 RED — `editor_hora_inline_test.dart`, **standalone, independent of the sheet**: drag/tap adjusts hour and
|
- [x] 10.1 RED — `editor_hora_inline_test.dart`, **standalone, independent of the sheet**: drag/tap adjusts hour and
|
||||||
minute, wraps at 23:59→00:00, exposes correct a11y labels.
|
minute, wraps at 23:59→00:00, exposes correct a11y labels. **Correction found at apply time**: Flutter
|
||||||
- [ ] 10.2 GREEN — implement `lib/widgets/editor_hora_inline.dart` (giant inline HH:MM display, drag/tap-to-adjust).
|
requires a `Semantics` node exposing `onIncrease`/`onDecrease` to ALSO carry `increasedValue`/`decreasedValue`
|
||||||
- [ ] 10.3 RED — update `pantalla_alarmas_fecha_test.dart`: one-time date alarm still creatable via the (now
|
whenever `value` is set (`SemanticsNode.updateWith`'s `(value == '') == (increasedValue == '')` assertion) —
|
||||||
|
added both, computed from the same pure delta-preview helpers the mutation uses. Also had to set
|
||||||
|
`excludeSemantics: true` on each segment's `Semantics` node: without it, the inner `GestureDetector`'s own
|
||||||
|
semantics contribution merged upward and (a) duplicated the digits into `label` via the child `Text`'s
|
||||||
|
implicit semantics and (b) auto-exposed `scrollUp`/`scrollDown` (Flutter's default accessibility mapping for
|
||||||
|
a registered vertical-drag recognizer) — neither wanted. Every action (`tap`/`increase`/`decrease`) is now
|
||||||
|
declared explicitly on the one Semantics node instead.
|
||||||
|
- [x] 10.2 GREEN — implement `lib/widgets/editor_hora_inline.dart` (giant inline HH:MM display, drag/tap-to-adjust).
|
||||||
|
Tap increments by one step (wrapping); vertical drag adjusts continuously (24px/step, chosen for a
|
||||||
|
comfortable scrub distance — not a reused measured constant, this widget has no other consumer).
|
||||||
|
- [x] 10.3 RED — update `pantalla_alarmas_fecha_test.dart`: one-time date alarm still creatable via the (now
|
||||||
collapsed) Advanced section; fallback-station picker and sound dropdown still settable and persisted.
|
collapsed) Advanced section; fallback-station picker and sound dropdown still settable and persisted.
|
||||||
- [ ] 10.4 GREEN — replace the native `showTimePicker` dialog with `EditorHoraInline`; keep weekday circles always
|
**Correction found at apply time**: this file previously held ONLY the 2 pure `fechaCortaLocalizada` format
|
||||||
|
tests, no widget coverage at all — extended it in place (kept both untouched) rather than creating a second
|
||||||
|
file, since the task's own instruction was to "update" this file. Also required a one-line surgical fix to
|
||||||
|
the PRE-EXISTING fallback-station test in `pantalla_alarmas_editor_test.dart` (not itself listed as a
|
||||||
|
"Modified test" for this WU): a collapsed `ExpansionTile` does not build its children, so
|
||||||
|
`find.byKey('alarm-fallback-station-field')` found nothing until the "Advanced" header is tapped first —
|
||||||
|
added that one tap, no scenario/assertion changed.
|
||||||
|
- [x] 10.4 GREEN — replace the native `showTimePicker` dialog with `EditorHoraInline`; keep weekday circles always
|
||||||
visible; move the date field, fallback-station picker, and sound dropdown into a collapsed "Advanced"
|
visible; move the date field, fallback-station picker, and sound dropdown into a collapsed "Advanced"
|
||||||
section rather than dropping them.
|
section rather than dropping them. **Design decision, not specified by any ADR (WU10 has none)**: the
|
||||||
- [ ] 10.5 GREEN — restyle the volume/fade-in sliders (cosmetic only, no behaviour change).
|
one-time/daily/weekly `SegmentedButton` stays in the main flow (not Advanced) since it gates which weekday
|
||||||
- [ ] 10.6 REFACTOR — confirm the dismiss-guard test is untouched by this WU (editor sheet only, not the ringing
|
circles are enabled; weekday circles now render unconditionally with `onSelected: null` (Material's standard
|
||||||
screen).
|
disabled-chip state) when `_tipo != diasSemana`, instead of being removed from the tree — no change to
|
||||||
- [ ] 10.7 Verify — standalone widget test green independent of the sheet; date/fallback/sound round-trip test
|
`_tipo`/`diasSemana`/`fechaUnica` persistence logic, confirmed the scheduler treats `diaria` and
|
||||||
green.
|
`diasSemana` as genuinely different code paths (`_buscarDiaria` vs `_buscarPorDiasSemana` in
|
||||||
|
`servicio_programacion_alarmas.dart`), so the two were never merged.
|
||||||
|
- [x] 10.5 GREEN — restyle the volume/fade-in sliders (cosmetic only, no behaviour change): swapped the raw
|
||||||
|
`Theme.of(context).textTheme` lookups on `_SectionLabel` ("Sonido y volumen") and the fade-in `ListTile`
|
||||||
|
title for `context.pluriType.cardTitle` — same weight class, now sharing the named type-scale token with
|
||||||
|
every other card/section title in the redesign. `Slider` widget types and their `min`/`max`/`value` wiring
|
||||||
|
are byte-identical, so `'el slider de volumen permite bajar hasta 0.0 (S2-R11)'` passes unmodified.
|
||||||
|
- [x] 10.6 REFACTOR — confirmed the dismiss-guard test is untouched by this WU (editor sheet only, not the ringing
|
||||||
|
screen): `git diff` empty for both `pantalla_alarma_sonando.dart` and
|
||||||
|
`pantalla_alarma_sonando_dismiss_guard_test.dart`; also removed the now-dead `_elegirHora()` method (its only
|
||||||
|
caller, the old time-picker `_PickerButton`, no longer exists).
|
||||||
|
- [x] 10.7 Verify — standalone widget test green (7/7); date/fallback/sound round-trip tests green (4/4 new in
|
||||||
|
`pantalla_alarmas_fecha_test.dart`); full WU10-scoped run (`editor_hora_inline_test.dart` +
|
||||||
|
`pantalla_alarmas_fecha_test.dart` + `pantalla_alarmas_editor_test.dart` + the two `estado_alarmas*` guard
|
||||||
|
files + all 3 `pantalla_alarma_sonando*` files + `pantalla_vacaciones_test.dart`): 82/82 green. `flutter
|
||||||
|
analyze`: 1 issue, identical to baseline.
|
||||||
|
|
||||||
**`size:exception` recommended.** ~500-650 lines — the largest genuinely-new widget in the plan; already isolated
|
**`size:exception` recorded.** Realized: **993 changed lines** (891+/102-) across 21 files — over the 500-650
|
||||||
as its own unit per the proposal ("never bundled"). See forecast table footnote ‡.
|
forecast, same "a strict-TDD commit carries its test files, and any ARB touch drags 13 generated files with it"
|
||||||
|
pattern as every prior WU (Engram `reference/estimating-strict-tdd-diffs`, id 2514). Breakdown: `lib/` production
|
||||||
|
code alone is 429 lines (`pantalla_alarmas.dart` 123+/102-, new `editor_hora_inline.dart` 204+/0-) — within the
|
||||||
|
forecast band on its own; the 3 new/modified test files add 376 lines and the 4 new ARB keys drag in 13 regenerated
|
||||||
|
`lib/l10n/gen/*.dart` files plus the 2 ARB sources for 188 more. Justification unchanged from the forecast table's
|
||||||
|
own footnote ‡: "the proposal already isolates WU10 as its own PR, never bundled — splitting further would leave an
|
||||||
|
unintegrated commit (a widget with no consumer, or a sheet rewrite with no new editor)."
|
||||||
|
|
||||||
## WU11 — Alarm ringing restyle
|
## WU11 — Alarm ringing restyle
|
||||||
|
|
||||||
@@ -642,19 +680,45 @@ Banner Survive the Restyle
|
|||||||
> **Hard rule, restated:** if the restyle appears to require changing the dismiss-guard test, the task is to STOP
|
> **Hard rule, restated:** if the restyle appears to require changing the dismiss-guard test, the task is to STOP
|
||||||
> and escalate — not to edit the test.
|
> and escalate — not to edit the test.
|
||||||
|
|
||||||
- [ ] 11.1 RED — update the two non-guard test files for the full-bleed blurred-art layout, giant 88px time
|
- [x] 11.1 RED — update the two non-guard test files for the full-bleed blurred-art layout, giant 88px time
|
||||||
(`heroTime`, wrapped in `FittedBox(fit: BoxFit.scaleDown)` per the text-scaling rule), 3 fixed snooze tiles
|
(`heroTime`, wrapped in `FittedBox(fit: BoxFit.scaleDown)` per the text-scaling rule), 3 fixed snooze tiles
|
||||||
(3/5/10 min, 10 highlighted), full-width stop pill.
|
(3/5/10 min, 10 highlighted), full-width stop pill. **Correction found at apply time**: the pre-existing
|
||||||
- [ ] 11.2 RED — the status label renders "Subiendo volumen" (or localized equivalent) with no accompanying
|
"3/5/10 mas el personalizado" test (snoozeMinutos=7 growing a 4th button) is exactly the behavior this WU
|
||||||
numeric value that changes over time.
|
replaces — rewrote it in place (not deleted) to assert the new "always exactly 3, no 4th tile" contract,
|
||||||
- [ ] 11.3 GREEN — restyle to a full-bleed art background; replace the `Wrap` of snooze chips with 3 fixed tiles;
|
plus 2 new tests pinning which tile is `FilledButton` (highlighted) vs `OutlinedButton`.
|
||||||
replace the glass-card container.
|
- [x] 11.2 RED — the status label renders "Subiendo volumen" (or localized equivalent) with no accompanying
|
||||||
- [ ] 11.4 GREEN — keep the status label static, sourced only from `widget.alarma` (unchanged from the existing
|
numeric value that changes over time. New ARB key `alarmVolumeRisingStatus` carries NO placeholder at all
|
||||||
documented constraint); preserve the force-stop retry banner in the new layout.
|
(unlike `alarmFadeInSummary`, which has `{seconds}`) — by construction this label can never grow a live
|
||||||
- [ ] 11.5 REFACTOR — run `pantalla_alarma_sonando_dismiss_guard_test.dart` **unchanged**; if it fails, STOP — do
|
counter without a deliberate key change. Gated on `alarma.fadeInSegundos > 0`: the existing `_montarPantalla`
|
||||||
not edit it, escalate instead.
|
test helper in `pantalla_alarma_sonando_test.dart` already carried an unused `fadeInSegundos` parameter
|
||||||
- [ ] 11.6 Verify — force-stop banner still appears on a simulated stop failure; dismiss-guard test file diff is
|
defaulting to 0, confirming this gate was anticipated ahead of this WU.
|
||||||
empty.
|
- [x] 11.3 GREEN — restyle to a full-bleed art background; replace the `Wrap` of snooze chips with 3 fixed tiles;
|
||||||
|
replace the glass-card container. No per-station artwork exists in this codebase (`Emisora.favicon` is a
|
||||||
|
network URL — rendering it via `Image.network` in a widget test hangs/throws without a mocked
|
||||||
|
`HttpClient`), so the existing bundled `alarm_music.png` asset is reused, blurred (`ImageFiltered`,
|
||||||
|
sigma 44) and stretched — decorative, not spec-tested.
|
||||||
|
- [x] 11.4 GREEN — keep the status label static, sourced only from `widget.alarma` (unchanged from the existing
|
||||||
|
documented constraint); preserve the force-stop retry banner in the new layout. `_bannerFalloDetencion` is
|
||||||
|
BYTE-IDENTICAL to its pre-WU11 form (same l10n keys, same widget structure) — only repositioned, never
|
||||||
|
rewritten, so every existing force-stop test (SS-3a/SS-3b/SS-3c/RES-2) keeps passing unmodified. **Design
|
||||||
|
decision, not specified by any ADR (WU11 has none)**: the "Subiendo volumen" status dot is intentionally
|
||||||
|
NOT animated/pulsing — an `AnimationController.repeat()` here would reintroduce the exact
|
||||||
|
"`pumpAndSettle()` never terminates" hazard WU5 documented for `VisualizadorAudio`.
|
||||||
|
- [x] 11.5 REFACTOR — ran `pantalla_alarma_sonando_dismiss_guard_test.dart` **unchanged**: all 8 cases green,
|
||||||
|
byte-identical, no edit needed or made.
|
||||||
|
- [x] 11.6 Verify — force-stop banner still appears on a simulated stop failure (all 3 force-stop scenarios green,
|
||||||
|
unmodified); dismiss-guard test file diff is empty against BOTH `HEAD` and `main`.
|
||||||
|
|
||||||
|
**`size:exception` recorded.** Realized: **530 changed lines** (440+/90-) across 3 files against the 200-300
|
||||||
|
forecast — `lib/` production code alone is 302 lines (248+/54-), essentially at the top edge of the forecast band
|
||||||
|
by itself; the 2 modified test files add 228 lines, the same "a strict-TDD commit carries its test files" pattern
|
||||||
|
as every prior WU (Engram `reference/estimating-strict-tdd-diffs`, id 2514) — this WU touches no ARB keys beyond
|
||||||
|
the 1 new `alarmVolumeRisingStatus` string, so no 13-file l10n/gen inflation this time; test growth alone accounts
|
||||||
|
for the overage. Not pre-flagged for exception in the forecast table (unlike WU10), but the same "one cohesive
|
||||||
|
visual+behavioral restyle to one safety-critical screen" reasoning applies: splitting the background/snooze-tiles/
|
||||||
|
stop-pill/status-label changes into separate commits would leave an inconsistent intermediate UI on the ringing
|
||||||
|
screen specifically — the one screen in this whole branch where an inconsistent intermediate state is least
|
||||||
|
acceptable.
|
||||||
|
|
||||||
## WU13 — Ecualizador settings screen (5-band restyle)
|
## WU13 — Ecualizador settings screen (5-band restyle)
|
||||||
|
|
||||||
@@ -671,32 +735,67 @@ Stations-With-Own-EQ Drill-Down, Base-vs-Per-Station Explainer Preserved, Active
|
|||||||
> **Hazard, restated: persistence for custom presets must NOT go in `servicio_ecualizador.dart`.** That file has an
|
> **Hazard, restated: persistence for custom presets must NOT go in `servicio_ecualizador.dart`.** That file has an
|
||||||
> empty-`git diff` success criterion.
|
> empty-`git diff` success criterion.
|
||||||
|
|
||||||
- [ ] 13.1 RED — the Ecualizador screen renders exactly **5** vertical sliders (regression guard — reject any
|
- [x] 13.1 RED — the Ecualizador screen renders exactly **5** vertical sliders (regression guard — reject any
|
||||||
future 7-slider change on sight).
|
future 7-slider change on sight). **Corrected at apply time**: named test file `ecualizador_widget_test.dart`
|
||||||
- [ ] 13.2 RED — `servicio_presets_personalizados_test.dart`: save/list/delete round-trip for a named custom preset
|
did not exist before this commit (`ecualizador_widget.dart` had zero coverage) — created new, plus the
|
||||||
|
screen-level assertion in `pantalla_ajustes_ecualizador_test.dart` (the spec scenario's own GIVEN is "the
|
||||||
|
Ecualizador settings screen").
|
||||||
|
- [x] 13.2 RED — `servicio_presets_personalizados_test.dart`: save/list/delete round-trip for a named custom preset
|
||||||
via its **own** SharedPreferences key `eq_custom_presets_v1`.
|
via its **own** SharedPreferences key `eq_custom_presets_v1`.
|
||||||
- [ ] 13.3 RED — submitting an empty/whitespace-only preset name shows a validation message and persists nothing.
|
- [x] 13.3 RED — submitting an empty/whitespace-only preset name shows a validation message and persists nothing.
|
||||||
- [ ] 13.4 RED — the "Emisoras con ajuste propio" drill-down lists exactly the stations present in
|
Covered at both the state layer (new `estado_ecualizador_presets_personalizados_test.dart` — see 13.7's note)
|
||||||
|
and the UI layer (`pantalla_ajustes_ecualizador_test.dart`'s "Guardar como preset" dialog scenarios).
|
||||||
|
- [x] 13.4 RED — the "Emisoras con ajuste propio" drill-down lists exactly the stations present in
|
||||||
`presetsPorEmisora`.
|
`presetsPorEmisora`.
|
||||||
- [ ] 13.5 RED — the "Salida activa" row is visible on the main screen (not only Advanced) and updates on a
|
- [x] 13.5 RED — the "Salida activa" row is visible on the main screen (not only Advanced) and updates on a
|
||||||
simulated device change; the base-vs-per-station explainer banner is visible.
|
simulated device change; the base-vs-per-station explainer banner is visible.
|
||||||
- [ ] 13.6 GREEN — create `lib/servicios/servicio_presets_personalizados.dart` (**new file**, own SharedPreferences
|
- [x] 13.6 GREEN — create `lib/servicios/servicio_presets_personalizados.dart` (**new file**, own SharedPreferences
|
||||||
key). **Do NOT add any member to `servicio_ecualizador.dart`.** `lib/modelos/preset_ecualizador.dart` stays
|
key). **Do NOT add any member to `servicio_ecualizador.dart`.** `lib/modelos/preset_ecualizador.dart` stays
|
||||||
unchanged — a user preset is a `PresetEcualizador` with a user-supplied `nombre`; `toJson`/`desdeJson`
|
unchanged — a user preset is a `PresetEcualizador` with a user-supplied `nombre`; `toJson`/`desdeJson`
|
||||||
already exist.
|
already exist.
|
||||||
- [ ] 13.7 GREEN — extend `EstadoEcualizador` with `presetsPersonalizados`, `guardarPresetPersonalizado(nombre)`,
|
- [x] 13.7 GREEN — extend `EstadoEcualizador` with `presetsPersonalizados`, `guardarPresetPersonalizado(nombre)`,
|
||||||
`eliminarPresetPersonalizado(nombre)`, reading/writing only through the new service.
|
`eliminarPresetPersonalizado(nombre)`, reading/writing only through the new service. **Correction found at
|
||||||
- [ ] 13.8 GREEN — restyle `lib/widgets/ecualizador_widget.dart` in place: strip the internal title + preset chip
|
apply time**: the new custom-preset LOAD (`cargarPresetsPersonalizados()`) is deliberately NOT folded into
|
||||||
row (lines 59-77 — the pushed header now carries the title), restyle the 5 `Card`/`RotatedBox`/`Slider`
|
`cargarPersistido()` — that method is exercised ~30 times by `estado_ecualizador_test.dart` (one of the three
|
||||||
tiles to the vertical-track look, add the `habilitado` parameter. Keep the `for (int i = 0; i < 5; i++)`
|
protected EQ test files) via Fakes for `servicio`/`dispositivoAudio` ONLY, with zero SharedPreferences
|
||||||
|
awareness anywhere in that file; adding a third always-real-by-default collaborator to it would have
|
||||||
|
introduced real SharedPreferences I/O into every one of those cases. Called explicitly from the screen's
|
||||||
|
`initState` instead (same pattern `refrescarDispositivoActual` already uses). New coverage lives in a NEW
|
||||||
|
file, `test/estado/estado_ecualizador_presets_personalizados_test.dart` — NOT added to
|
||||||
|
`estado_ecualizador_test.dart`, which stays unmodified.
|
||||||
|
- [x] 13.8 GREEN — restyle `lib/widgets/ecualizador_widget.dart` in place: strip the internal title + preset chip
|
||||||
|
row (re-verified at the CURRENT lines 59-77 of the pre-WU13 file, not trusted from the design doc's
|
||||||
|
reference), restyle the 5 `Card`/`RotatedBox`/`Slider` tiles to the vertical-track look (token-driven accent
|
||||||
|
colour, `AnimatedOpacity` grey-out), add the `habilitado` parameter (disables + greys every slider).
|
||||||
|
`PresetsEcualizadorWidget` additively widened with an optional `personalizados` list (default empty) so
|
||||||
|
custom presets can join the chip row without a second implementation. Kept the `for (int i = 0; i < 5; i++)`
|
||||||
loop bound literally `5`.
|
loop bound literally `5`.
|
||||||
- [ ] 13.9 GREEN — build `lib/pantallas/ajustes/pantalla_ajustes_ecualizador.dart` wiring
|
- [x] 13.9 GREEN — build out `lib/pantallas/ajustes/pantalla_ajustes_ecualizador.dart` (the WU3a placeholder body)
|
||||||
`EstadoEcualizador.presetPrincipal` / `cambiarPresetPrincipal`, surfacing "Salida activa" and the drill-down
|
wiring `EstadoEcualizador.presetPrincipal` / `cambiarPresetPrincipal`, surfacing "Salida activa" (own private
|
||||||
row.
|
row, live via the existing device-change `notifyListeners()`) and the drill-down row (own private row +
|
||||||
- [ ] 13.10 REFACTOR — reviewer check: confirm `git diff` is **empty** for `lib/servicios/servicio_ecualizador.dart`,
|
destination screen, station uuids resolved against `EstadoRadio.listaFavoritos` with a raw-uuid fallback for
|
||||||
|
a non-favorite station). Added the base-vs-per-station explainer banner and the "Guardar como preset" action
|
||||||
|
+ dialog (validates via `guardarPresetPersonalizado`, shows an inline `errorText` on empty/whitespace name).
|
||||||
|
- [x] 13.10 REFACTOR — reviewer check: confirmed `git diff` is **empty** for `lib/servicios/servicio_ecualizador.dart`,
|
||||||
`lib/modelos/preset_ecualizador.dart`, and the band-application block at
|
`lib/modelos/preset_ecualizador.dart`, and the band-application block at
|
||||||
`lib/servicios/servicio_audio.dart:749-762`.
|
`lib/servicios/servicio_audio.dart:749-762` (this WU never touches `servicio_audio.dart` at all).
|
||||||
- [ ] 13.11 Verify — the three EQ test files pass **unmodified**; exactly 5 sliders render.
|
- [x] 13.11 Verify — the three EQ test files (`servicio_ecualizador_test.dart`, `estado_ecualizador_test.dart`,
|
||||||
|
`servicio_audio_eq_reapply_test.dart`) pass **unmodified**; exactly 5 sliders render (component AND screen
|
||||||
|
level). Full suite: 713/713 green (2 skipped, unchanged), up from 682. `flutter analyze`: 1 issue, identical
|
||||||
|
to baseline. Literal-encoding scan: zero hits.
|
||||||
|
|
||||||
|
**`size:exception` recorded.** Realized: **1,871 changed lines** (1,793+/78-) across 25 files against the 400-550
|
||||||
|
forecast — same "a strict-TDD commit carries its test files, and any ARB touch drags 13 generated files with it"
|
||||||
|
pattern as every prior WU (Engram `reference/estimating-strict-tdd-diffs`, id 2514). Breakdown: `lib/` production
|
||||||
|
code alone is 628 lines (`pantalla_ajustes_ecualizador.dart` +326, `ecualizador_widget.dart` restyle ~151,
|
||||||
|
`estado_ecualizador.dart` +64, new `servicio_presets_personalizados.dart` +87) plus 22 ARB source lines — at the top
|
||||||
|
edge of the forecast band by itself given this WU also had to build out a screen body WU3a only stubbed; the 4
|
||||||
|
test files (2 new state/service-layer files plus the widget test and the extended screen test) add 675 lines, and
|
||||||
|
11 new ARB keys drag in 546 more via the 13 regenerated `lib/l10n/gen/*.dart` files. Justification: the restyle,
|
||||||
|
the new persistence service, the state-layer extension and the screen wiring (explainer/salida-activa/drill-down/
|
||||||
|
save-flow) are one cohesive vertical slice — WU13 must land as a whole before WU14 can reuse its editor component
|
||||||
|
by exact runtime type; splitting further would leave either an unstyled widget with no screen consumer or a screen
|
||||||
|
half-wired to a service that doesn't exist yet.
|
||||||
|
|
||||||
## WU14 — Reproductor completo + per-station EQ sheet
|
## WU14 — Reproductor completo + per-station EQ sheet
|
||||||
|
|
||||||
@@ -708,24 +807,67 @@ Per-Station EQ Entry Relocates, Resolution Logic Does Not
|
|||||||
**Modified tests**: `pantalla_reproductor_test.dart` (or equivalent widget test). Three EQ test files must pass
|
**Modified tests**: `pantalla_reproductor_test.dart` (or equivalent widget test). Three EQ test files must pass
|
||||||
**unmodified**.
|
**unmodified**.
|
||||||
|
|
||||||
- [ ] 14.1 RED — the per-station EQ bottom sheet renders **the same `EcualizadorWidget` type** WU13 restyled
|
- [x] 14.1 RED — the per-station EQ bottom sheet renders **the same `EcualizadorWidget` type** WU13 restyled
|
||||||
(assert by `runtimeType`, so a duplicate implementation fails the test, not just a visual review).
|
(assert by `runtimeType`, so a duplicate implementation fails the test, not just a visual review).
|
||||||
- [ ] 14.2 RED — the 4 tool-tray tiles ("EQ propio", "Grabar", sleep-timer value, "Compartir") each open their own
|
- [x] 14.2 RED — the 4 tool-tray tiles ("EQ propio", "Grabar", sleep-timer value, "Compartir") each open their own
|
||||||
bottom sheet.
|
bottom sheet (Compartir invokes directly — there is no sheet content for a share action).
|
||||||
- [ ] 14.3 RED — opening "EQ propio" for a playing station shows 5 sliders bound to that station's resolved preset,
|
- [x] 14.3 RED — opening "EQ propio" for a playing station shows 5 sliders bound to that station's resolved preset,
|
||||||
and a change round-trips through the existing per-station persistence path (`presetsPorEmisora` /
|
and a change round-trips through the existing per-station persistence path (`presetsPorEmisora` /
|
||||||
`presetsMatriz`).
|
`presetsMatriz`).
|
||||||
- [ ] 14.4 GREEN — restructure `pantalla_reproductor.dart`: square art, favorite moved into the transport row,
|
- [x] 14.4 GREEN — restructured `pantalla_reproductor.dart` onto `PluriPushScaffold` (design ADR-2 — this screen is
|
||||||
single subtitle line (collapse the current `_InfoChips` `Wrap`), 4-tile tool-tray grid replacing the
|
the documented single consumer of `titleOverride`, a centered live/not-playing `PluriStatusPill`, and of the
|
||||||
always-expanded recording panel + separate sleep-timer button + EQ toggle.
|
non-default `leadingIcon: keyboard_arrow_down_rounded`): square art (`ClipRRect`, was `ClipOval`), favorite
|
||||||
- [ ] 14.5 GREEN — wire "EQ propio" to a bottom sheet hosting `EcualizadorWidget` bound via
|
moved into the transport row (the old redundant live-indicator dot removed — the AppBar pill already covers
|
||||||
`EstadoEcualizador.presetParaEmisora(uuid)` / `guardarPresetPorEmisora(uuid, ...)`.
|
that signal, so "favorite / stop / play-pause" matches the proposal's own "3 controles" note), single
|
||||||
- [ ] 14.6 GREEN — add the quality row + "Cambiar" action and the "Compartir" tool-tray tile.
|
subtitle line (collapses the old `_InfoChips` `Wrap`; codec/bitrate moved to the new quality row), 4-tile
|
||||||
- [ ] 14.7 REFACTOR — confirm no second EQ editor file was created; confirm the `multi-device-eq` regression
|
tool-tray row replacing the always-expanded recording panel + separate sleep-timer button + EQ toggle. Body
|
||||||
scenarios (device-event resolve-and-apply, first-seen bootstrap, cold start, connect/disconnect/reconnect,
|
wrapped in `SingleChildScrollView` (was a fixed `Column` + `Spacer()`, which overflowed even a generously
|
||||||
toggle-off) still pass unmodified.
|
tall test viewport — see 2 bugfixes below).
|
||||||
- [ ] 14.8 Verify — the 3 EQ test files remain green and unmodified; `EcualizadorWidget` type-identity assertion
|
- [x] 14.5 GREEN — wired "EQ propio" to a bottom sheet hosting `EcualizadorWidget` bound via
|
||||||
passes.
|
`EstadoEcualizador.presetParaEmisora(uuid)` / `guardarPresetPorEmisora(uuid, ...)`. Deliberately just the 5
|
||||||
|
sliders, no preset-chip row — the spec scenario's own wording is "5 sliders", and ADR-5's wiring table only
|
||||||
|
names `EcualizadorWidget` for this consumer.
|
||||||
|
- [x] 14.6 GREEN — added the quality row + "Cambiar" action and the "Compartir" tool-tray tile. **Design decision,
|
||||||
|
not specified by any ADR (WU14 has none)**: this app has no per-station alternate-quality capability to
|
||||||
|
invoke (a Radio Browser station is one fixed stream) — "Cambiar" reconnects the current stream (the SAME
|
||||||
|
`estado.reproducir(emisora)` call the existing error-state "Retry" button already uses) instead of a dead
|
||||||
|
button or an invented picker, matching this branch's "don't invent a capability absent from the domain"
|
||||||
|
discipline (WU5 per-station artwork, WU9 dashed border). "Compartir" mirrors WU15's injectable `compartir`
|
||||||
|
constructor-parameter pattern (defaults to the real `share_plus` call), sharing the station name + stream url.
|
||||||
|
- [x] 14.7 REFACTOR — confirmed no second EQ editor file was created (`grep`-equivalent: only one `EcualizadorWidget`
|
||||||
|
class exists, in `lib/widgets/ecualizador_widget.dart`, imported and reused here); confirmed the
|
||||||
|
`multi-device-eq` regression scenarios in `estado_ecualizador_test.dart`'s "4-level resolution (Phase 5)"
|
||||||
|
group still pass unmodified (this WU never touches `EstadoEcualizador`'s resolution logic, only calls its
|
||||||
|
EXISTING `presetParaEmisora`/`guardarPresetPorEmisora` methods). Removed dead code found during the
|
||||||
|
restructure: `_pulseController` (an `AnimationController` created and disposed but never actually driven by
|
||||||
|
anything) and the `SingleTickerProviderStateMixin` it required.
|
||||||
|
- [x] 14.8 Verify — the 3 EQ test files remain green and **unmodified**; `EcualizadorWidget` type-identity assertion
|
||||||
|
passes (both via `find.byType` and a `runtimeType`-predicate structural regression guard). Full suite:
|
||||||
|
730/730 green (2 skipped, unchanged), up from 713.
|
||||||
|
|
||||||
|
**Two pre-existing bugs found and fixed, surfaced by writing this screen's first-ever test coverage** (both
|
||||||
|
directly blocked test coverage from working at all, so neither could be deferred):
|
||||||
|
1. `initState` called `estado.reproducir(...)` directly, which calls `notifyListeners()` **synchronously** before
|
||||||
|
its first `await` when no recording needs stopping — threw "setState() or markNeedsBuild() called during
|
||||||
|
build" the instant this screen mounted against a fresh Provider tree. Fixed via `WidgetsBinding.instance.
|
||||||
|
addPostFrameCallback`.
|
||||||
|
2. The body `Column` had no scrollable ancestor and overflowed the default 800x600 test viewport (and would
|
||||||
|
overflow on a genuinely short real device too, given the content: hero, name, subtitle, quality row, visualizer,
|
||||||
|
transport, tool tray). Fixed by wrapping the body in `SingleChildScrollView` (see 14.4) — a real UX improvement,
|
||||||
|
not just a test workaround.
|
||||||
|
|
||||||
|
**`size:exception` recorded.** Realized: **1,335 changed lines** (1,114+/221-) across 19 files against the
|
||||||
|
450-600 forecast — same "a strict-TDD commit carries its test files" pattern as every prior WU (Engram
|
||||||
|
`reference/estimating-strict-tdd-diffs`, id 2514), though smaller this time since only 2 new ARB keys were needed
|
||||||
|
(`playerToolEqLabel`, `playerQualityChangeAction` — everything else reused existing keys: `recordAction`,
|
||||||
|
`recordingActiveTitle`, `sleepTimer`, `recordingActionShare`, `liveNow`, `notPlaying`, `qualityOriginal`,
|
||||||
|
`qualityUnknown`). Breakdown: `lib/pantallas/pantalla_reproductor.dart` alone is 658 lines (a near-total
|
||||||
|
restructure of a 907-line file, not a small patch); the new `pantalla_reproductor_test.dart` (writing coverage for
|
||||||
|
a file that had ZERO before this commit, per this WU's own explicit mandate) is 519 lines; `test/helpers/fakes.dart`
|
||||||
|
gained 64 (new `FakeServicioGrabacionRadioActivable` plus a `togglePlay()` override the play/pause characterization
|
||||||
|
test needed); the rest is the 2-key ARB/l10n-gen cascade. Not splittable: the restructure, the tool tray, and the
|
||||||
|
EQ-sheet wiring are one cohesive change to one screen — a split would leave either an unstyled screen with a tool
|
||||||
|
tray that has nothing to open, or a tool tray with no restructured screen to live in.
|
||||||
|
|
||||||
## WU15 — Grabaciones library (new list)
|
## WU15 — Grabaciones library (new list)
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -449,10 +449,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -798,10 +798,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.11"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_ecualizador.dart';
|
||||||
|
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||||
|
|
||||||
|
import '../helpers/fakes.dart';
|
||||||
|
|
||||||
|
/// WU13 task 13.7 — `EstadoEcualizador`'s new custom-preset members
|
||||||
|
/// (`presetsPersonalizados`, `guardarPresetPersonalizado`,
|
||||||
|
/// `eliminarPresetPersonalizado`), reading/writing only through the new
|
||||||
|
/// `ServicioPresetsPersonalizados` (design ADR-5 hazard box).
|
||||||
|
///
|
||||||
|
/// **Correction found at apply time, deliberate**: this is a NEW file, not
|
||||||
|
/// an addition to `estado_ecualizador_test.dart`. That file is one of the
|
||||||
|
/// three EQ test files this change's master guard requires to pass
|
||||||
|
/// **unmodified** — it is exercised ~30 times via `cargarPersistido()`
|
||||||
|
/// using Fakes for `servicio`/`dispositivoAudio` ONLY, with no
|
||||||
|
/// SharedPreferences awareness anywhere in the file. Because of that,
|
||||||
|
/// `cargarPersistido()` itself is intentionally left untouched by this WU
|
||||||
|
/// (see `cargarPresetsPersonalizados` below) rather than folding a third,
|
||||||
|
/// always-real-by-default collaborator into a method exercised by a file
|
||||||
|
/// that must never change.
|
||||||
|
void main() {
|
||||||
|
EstadoEcualizador buildEq({FakeServicioPresetsPersonalizados? servicio}) {
|
||||||
|
return EstadoEcualizador(
|
||||||
|
audio: FakeServicioAudio(),
|
||||||
|
servicio: FakeServicioEcualizador(),
|
||||||
|
presetsPersonalizadosService:
|
||||||
|
servicio ?? FakeServicioPresetsPersonalizados(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('presetsPersonalizados starts empty before loading', () {
|
||||||
|
final eq = buildEq();
|
||||||
|
|
||||||
|
expect(eq.presetsPersonalizados, isEmpty);
|
||||||
|
eq.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'cargarPresetsPersonalizados populates presetsPersonalizados from the service',
|
||||||
|
() async {
|
||||||
|
final fakeServicio = FakeServicioPresetsPersonalizados();
|
||||||
|
final preexistente = PresetEcualizador(
|
||||||
|
nombre: 'Ya guardado',
|
||||||
|
bandas: [1, 2, 3, 4, 5],
|
||||||
|
);
|
||||||
|
await fakeServicio.guardar(preexistente);
|
||||||
|
final eq = buildEq(servicio: fakeServicio);
|
||||||
|
|
||||||
|
await eq.cargarPresetsPersonalizados();
|
||||||
|
|
||||||
|
expect(eq.presetsPersonalizados, hasLength(1));
|
||||||
|
expect(eq.presetsPersonalizados.single.nombre, equals('Ya guardado'));
|
||||||
|
eq.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'guardarPresetPersonalizado saves the CURRENT effective preset bands under the given name',
|
||||||
|
() async {
|
||||||
|
final eq = buildEq();
|
||||||
|
await eq.cambiarPreset(PresetEcualizador.jazz);
|
||||||
|
|
||||||
|
final guardado = await eq.guardarPresetPersonalizado('Mi preset');
|
||||||
|
|
||||||
|
expect(guardado, isTrue);
|
||||||
|
expect(eq.presetsPersonalizados, hasLength(1));
|
||||||
|
expect(eq.presetsPersonalizados.single.nombre, equals('Mi preset'));
|
||||||
|
expect(
|
||||||
|
eq.presetsPersonalizados.single.bandas,
|
||||||
|
equals(PresetEcualizador.jazz.bandas),
|
||||||
|
);
|
||||||
|
eq.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('guardarPresetPersonalizado notifies listeners on success', () async {
|
||||||
|
final eq = buildEq();
|
||||||
|
var avisos = 0;
|
||||||
|
eq.addListener(() => avisos++);
|
||||||
|
|
||||||
|
await eq.guardarPresetPersonalizado('Preset con nombre');
|
||||||
|
|
||||||
|
expect(avisos, greaterThanOrEqualTo(1));
|
||||||
|
eq.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'guardarPresetPersonalizado rejects an empty name: no save, returns false',
|
||||||
|
() async {
|
||||||
|
final eq = buildEq();
|
||||||
|
|
||||||
|
final guardado = await eq.guardarPresetPersonalizado('');
|
||||||
|
|
||||||
|
expect(guardado, isFalse);
|
||||||
|
expect(eq.presetsPersonalizados, isEmpty);
|
||||||
|
eq.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'guardarPresetPersonalizado rejects a whitespace-only name: no save, returns false',
|
||||||
|
() async {
|
||||||
|
final eq = buildEq();
|
||||||
|
|
||||||
|
final guardado = await eq.guardarPresetPersonalizado(' ');
|
||||||
|
|
||||||
|
expect(guardado, isFalse);
|
||||||
|
expect(eq.presetsPersonalizados, isEmpty);
|
||||||
|
eq.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'guardarPresetPersonalizado trims surrounding whitespace from a valid name',
|
||||||
|
() async {
|
||||||
|
final eq = buildEq();
|
||||||
|
|
||||||
|
final guardado = await eq.guardarPresetPersonalizado(' Con espacios ');
|
||||||
|
|
||||||
|
expect(guardado, isTrue);
|
||||||
|
expect(eq.presetsPersonalizados.single.nombre, equals('Con espacios'));
|
||||||
|
eq.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'eliminarPresetPersonalizado removes a previously saved preset',
|
||||||
|
() async {
|
||||||
|
final eq = buildEq();
|
||||||
|
await eq.guardarPresetPersonalizado('Para borrar');
|
||||||
|
expect(eq.presetsPersonalizados, hasLength(1));
|
||||||
|
|
||||||
|
await eq.eliminarPresetPersonalizado('Para borrar');
|
||||||
|
|
||||||
|
expect(eq.presetsPersonalizados, isEmpty);
|
||||||
|
eq.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'guardarPresetPersonalizado reads/writes only through the injected service '
|
||||||
|
'(never touches ServicioEcualizador)',
|
||||||
|
() async {
|
||||||
|
final fakeEcualizador = FakeServicioEcualizador();
|
||||||
|
final eq = EstadoEcualizador(
|
||||||
|
audio: FakeServicioAudio(),
|
||||||
|
servicio: fakeEcualizador,
|
||||||
|
presetsPersonalizadosService: FakeServicioPresetsPersonalizados(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await eq.guardarPresetPersonalizado('Aislado');
|
||||||
|
|
||||||
|
// ServicioEcualizador's own config is completely untouched by the
|
||||||
|
// custom-preset save — it lives in a separate service (ADR-5 hazard
|
||||||
|
// box), not folded into servicio_ecualizador.dart's persistence.
|
||||||
|
expect(fakeEcualizador.config.porEmisora, isEmpty);
|
||||||
|
eq.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart';
|
|||||||
import 'package:pluriwave/servicios/servicio_ecualizador.dart';
|
import 'package:pluriwave/servicios/servicio_ecualizador.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_favoritos.dart';
|
import 'package:pluriwave/servicios/servicio_favoritos.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
||||||
|
import 'package:pluriwave/servicios/servicio_presets_personalizados.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_radio.dart';
|
import 'package:pluriwave/servicios/servicio_radio.dart';
|
||||||
|
|
||||||
class FakeServicioAudio extends ServicioAudio {
|
class FakeServicioAudio extends ServicioAudio {
|
||||||
@@ -76,6 +77,21 @@ class FakeServicioAudio extends ServicioAudio {
|
|||||||
emitirEstado(EstadoReproduccion.pausado);
|
emitirEstado(EstadoReproduccion.pausado);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WU14: the real ServicioAudio.togglePlay() reads `_handler.playbackState`
|
||||||
|
// (a real just_audio-backed handler that requires registrarHandler(), same
|
||||||
|
// gap already documented for androidAudioSessionIdStream above) — unsafe
|
||||||
|
// against a bare FakeServicioAudio. Overridden here using only this Fake's
|
||||||
|
// own state machinery so `pantalla_reproductor.dart`'s play/pause control
|
||||||
|
// (previously untested) can be exercised safely.
|
||||||
|
@override
|
||||||
|
Future<void> togglePlay() async {
|
||||||
|
if (_estadoActual == EstadoReproduccion.reproduciendo) {
|
||||||
|
await pausar();
|
||||||
|
} else {
|
||||||
|
emitirEstado(EstadoReproduccion.reproduciendo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setVolumen(double vol) async {
|
Future<void> setVolumen(double vol) async {
|
||||||
volumenesAplicados.add(vol);
|
volumenesAplicados.add(vol);
|
||||||
@@ -477,6 +493,27 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In-memory fake for [ServicioPresetsPersonalizados] (WU13). Avoids any
|
||||||
|
/// real SharedPreferences I/O in state-layer tests, matching every other
|
||||||
|
/// `Fake*` service in this file.
|
||||||
|
class FakeServicioPresetsPersonalizados extends ServicioPresetsPersonalizados {
|
||||||
|
final List<PresetEcualizador> _presets = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PresetEcualizador>> listar() async => List.from(_presets);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> guardar(PresetEcualizador preset) async {
|
||||||
|
_presets.removeWhere((p) => p.nombre == preset.nombre);
|
||||||
|
_presets.add(preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> eliminar(String nombre) async {
|
||||||
|
_presets.removeWhere((p) => p.nombre == nombre);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A [ServicioDispositivoAudio] fake that throws on [obtenerDispositivoActual].
|
/// A [ServicioDispositivoAudio] fake that throws on [obtenerDispositivoActual].
|
||||||
///
|
///
|
||||||
/// Used to test the graceful-failure path in [EstadoEcualizador.cargarPersistido].
|
/// Used to test the graceful-failure path in [EstadoEcualizador.cargarPersistido].
|
||||||
@@ -617,6 +654,55 @@ class FakeServicioGrabacionRadio extends ServicioGrabacionRadio {
|
|||||||
Future<void> dispose() => _controller.close();
|
Future<void> dispose() => _controller.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// WU14: a recording fake that actually responds to `iniciar`/`detener`
|
||||||
|
/// in-memory, never touching real files or platform channels (`iniciar` on
|
||||||
|
/// the real `ServicioGrabacionRadio` opens an HTTP stream to the station's
|
||||||
|
/// URL and writes to disk — unsafe inside a widget test). Records every
|
||||||
|
/// call for assertions.
|
||||||
|
class FakeServicioGrabacionRadioActivable extends ServicioGrabacionRadio {
|
||||||
|
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
|
||||||
|
EstadoGrabacionRadio _estadoActual = const EstadoGrabacionRadio.inactiva();
|
||||||
|
final List<Duration?> duracionesIniciadas = [];
|
||||||
|
Emisora? ultimaEmisoraIniciada;
|
||||||
|
int detenerCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
EstadoGrabacionRadio get estado => _estadoActual;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> inicializar() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> iniciar(
|
||||||
|
Emisora emisora, {
|
||||||
|
Duration? duracion,
|
||||||
|
String? directorio,
|
||||||
|
}) async {
|
||||||
|
ultimaEmisoraIniciada = emisora;
|
||||||
|
duracionesIniciadas.add(duracion);
|
||||||
|
_estadoActual = EstadoGrabacionRadio(
|
||||||
|
tipo: EstadoGrabacionRadioTipo.grabando,
|
||||||
|
emisora: emisora,
|
||||||
|
inicio: DateTime.now(),
|
||||||
|
duracionObjetivo: duracion,
|
||||||
|
);
|
||||||
|
_controller.add(_estadoActual);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> detener() async {
|
||||||
|
detenerCalls++;
|
||||||
|
_estadoActual = const EstadoGrabacionRadio.inactiva();
|
||||||
|
_controller.add(_estadoActual);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() => _controller.close();
|
||||||
|
}
|
||||||
|
|
||||||
Emisora emisoraDemo({
|
Emisora emisoraDemo({
|
||||||
required String uuid,
|
required String uuid,
|
||||||
required String nombre,
|
required String nombre,
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:pluriwave/estado/estado_ecualizador.dart';
|
import 'package:pluriwave/estado/estado_ecualizador.dart';
|
||||||
import 'package:pluriwave/estado/estado_radio.dart';
|
import 'package:pluriwave/estado/estado_radio.dart';
|
||||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
|
import 'package:pluriwave/modelos/dispositivo_audio.dart';
|
||||||
|
import 'package:pluriwave/modelos/emisora.dart';
|
||||||
|
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_ecualizador.dart';
|
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_ecualizador.dart';
|
||||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -17,6 +20,12 @@ import '../../helpers/fakes_alarmas.dart';
|
|||||||
/// a [PluriPushScaffold] and its moved controls (the enable switch) still
|
/// a [PluriPushScaffold] and its moved controls (the enable switch) still
|
||||||
/// respond exactly as they did inside the old `_SeccionEcualizador`.
|
/// respond exactly as they did inside the old `_SeccionEcualizador`.
|
||||||
///
|
///
|
||||||
|
/// WU13 restyled the header away (design ADR-5 — the strip that used to
|
||||||
|
/// duplicate "Equalizer" as `EcualizadorWidget`'s own internal title is
|
||||||
|
/// gone), added the base-vs-per-station explainer, the "Salida activa"
|
||||||
|
/// row, the "Emisoras con ajuste propio" drill-down and the custom-preset
|
||||||
|
/// save flow — see the `WU13` group below.
|
||||||
|
///
|
||||||
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||||
/// PluriGlassSurface paints a background over ListTile's ink layer (here,
|
/// PluriGlassSurface paints a background over ListTile's ink layer (here,
|
||||||
/// via SwitchListTile), which Flutter flags as a warning-level assertion,
|
/// via SwitchListTile), which Flutter flags as a warning-level assertion,
|
||||||
@@ -43,17 +52,33 @@ void main() {
|
|||||||
'${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json',
|
'${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json',
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<EstadoRadio> crearEstado() async {
|
Future<EstadoRadio> crearEstado({
|
||||||
|
Map<String, PresetEcualizador>? porEmisora,
|
||||||
|
FakeServicioDispositivoAudio? dispositivoAudio,
|
||||||
|
bool eqMultiDeviceEnabled = false,
|
||||||
|
List<Emisora> favoritosIniciales = const [],
|
||||||
|
}) async {
|
||||||
|
final favoritos = FakeServicioFavoritos();
|
||||||
|
for (final emisora in favoritosIniciales) {
|
||||||
|
await favoritos.agregar(emisora);
|
||||||
|
}
|
||||||
final estado = EstadoRadio(
|
final estado = EstadoRadio(
|
||||||
audio: FakeServicioAudio(),
|
audio: FakeServicioAudio(),
|
||||||
favoritos: FakeServicioFavoritos(),
|
favoritos: favoritos,
|
||||||
radio: FakeServicioRadio(),
|
radio: FakeServicioRadio(),
|
||||||
servicioEcualizador: FakeServicioEcualizador(),
|
servicioEcualizador: FakeServicioEcualizador(
|
||||||
|
porEmisora: porEmisora,
|
||||||
|
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
|
||||||
|
),
|
||||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||||
|
dispositivoAudio: dispositivoAudio,
|
||||||
resolverArchivoCustom: archivoCustomVacio,
|
resolverArchivoCustom: archivoCustomVacio,
|
||||||
iniciarAutomaticamente: false,
|
iniciarAutomaticamente: false,
|
||||||
);
|
);
|
||||||
await estado.ecualizador.cargarPersistido();
|
await estado.ecualizador.cargarPersistido();
|
||||||
|
if (favoritosIniciales.isNotEmpty) {
|
||||||
|
await estado.cargarFavoritos();
|
||||||
|
}
|
||||||
return estado;
|
return estado;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,10 +107,11 @@ void main() {
|
|||||||
await tester.pumpWidget(buildScreen(estado));
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// "Equalizer" also appears inside EcualizadorWidget's own pre-existing
|
// WU13 stripped EcualizadorWidget's own internal "Equalizer" title +
|
||||||
// internal header, which WU3a does not touch (ecualizador_widget.dart's
|
// preset Chip row (design ADR-5) — the pushed screen's AppBar is now
|
||||||
// header strip is WU13's job per design ADR-5) — so we assert on the
|
// the ONLY place this title renders. Still asserting on the AppBar
|
||||||
// AppBar's title specifically rather than a bare text match.
|
// specifically (not a bare text match) keeps this test meaningful even
|
||||||
|
// if a future WU reintroduces a second on-screen occurrence.
|
||||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||||
final appBar = tester.widget<AppBar>(find.byType(AppBar));
|
final appBar = tester.widget<AppBar>(find.byType(AppBar));
|
||||||
expect((appBar.title as Text).data, equals('Equalizer'));
|
expect((appBar.title as Text).data, equals('Equalizer'));
|
||||||
@@ -107,4 +133,242 @@ void main() {
|
|||||||
|
|
||||||
expect(estado.ecualizador.activo, equals(!before));
|
expect(estado.ecualizador.activo, equals(!before));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('WU13 — restyle, custom presets, salida activa, drill-down', () {
|
||||||
|
testWidgets('exactly 5 sliders render on the Ecualizador screen', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// Spec `eq-custom-presets` "Five-Band Equalizer (Regression Guard)":
|
||||||
|
// GIVEN the user opens the Ecualizador screen THEN exactly 5 sliders
|
||||||
|
// render — asserted here at the SCREEN level (task 13.1's own GIVEN),
|
||||||
|
// in addition to the component-level guard in
|
||||||
|
// `ecualizador_widget_test.dart`.
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(Slider), findsNWidgets(5));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('base-vs-per-station explainer banner is visible', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('eq-base-explainer-banner')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Salida activa row is visible and shows a default label when no device is tracked',
|
||||||
|
(tester) async {
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('eq-active-output-row')), findsOneWidget);
|
||||||
|
expect(find.text('Active output'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('eq-active-output-row')),
|
||||||
|
matching: find.text("This device's speaker"),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('Salida activa row updates on a simulated device change', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final dispositivoAudio = FakeServicioDispositivoAudio();
|
||||||
|
final estado = await crearEstado(
|
||||||
|
dispositivoAudio: dispositivoAudio,
|
||||||
|
eqMultiDeviceEnabled: true,
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
dispositivoAudio.emitirDispositivo(
|
||||||
|
const DispositivoAudio(
|
||||||
|
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
|
||||||
|
tipo: TipoDispositivo.bluetoothA2dp,
|
||||||
|
nombre: 'Auriculares BT',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('eq-active-output-row')),
|
||||||
|
matching: find.text('Auriculares BT'),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Emisoras con ajuste propio drill-down lists exactly the overridden stations',
|
||||||
|
(tester) async {
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
const favorita = Emisora(
|
||||||
|
uuid: 'uuid-favorita',
|
||||||
|
nombre: 'Radio Favorita',
|
||||||
|
url: 'https://example.com/favorita',
|
||||||
|
);
|
||||||
|
final estado = await crearEstado(
|
||||||
|
porEmisora: {
|
||||||
|
'uuid-favorita': PresetEcualizador.rock,
|
||||||
|
'uuid-no-favorita': PresetEcualizador.jazz,
|
||||||
|
},
|
||||||
|
favoritosIniciales: const [favorita],
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('eq-stations-own-eq-row')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Text>(find.byKey(const Key('eq-stations-own-eq-count')))
|
||||||
|
.data,
|
||||||
|
equals('2'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('eq-stations-own-eq-row')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The favorite station resolves to its display name; the other
|
||||||
|
// uuid (never seen among favorites) falls back to the raw uuid —
|
||||||
|
// same fallback chain `nombreVisible` already uses for devices.
|
||||||
|
expect(find.text('Radio Favorita'), findsOneWidget);
|
||||||
|
expect(find.text('uuid-no-favorita'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'drill-down shows an empty state when no station has its own EQ',
|
||||||
|
(tester) async {
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('eq-stations-own-eq-row')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('No stations have their own EQ yet.'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Guardar como preset persists the current bands; the preset appears in the chip row',
|
||||||
|
(tester) async {
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.ensureVisible(
|
||||||
|
find.byKey(const Key('eq-save-preset-action')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('eq-save-preset-action')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const Key('eq-save-preset-name-field')),
|
||||||
|
'Mi preset',
|
||||||
|
);
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const Key('eq-save-preset-confirm-button')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(AlertDialog), findsNothing);
|
||||||
|
expect(find.text('Mi preset'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
estado.ecualizador.presetsPersonalizados.map((p) => p.nombre),
|
||||||
|
contains('Mi preset'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Guardar como preset with an empty name shows a validation message and persists nothing',
|
||||||
|
(tester) async {
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.ensureVisible(
|
||||||
|
find.byKey(const Key('eq-save-preset-action')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('eq-save-preset-action')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const Key('eq-save-preset-confirm-button')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(AlertDialog), findsOneWidget);
|
||||||
|
expect(find.text('Enter a name for the preset.'), findsOneWidget);
|
||||||
|
expect(estado.ecualizador.presetsPersonalizados, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Guardar como preset with a whitespace-only name shows the same validation message',
|
||||||
|
(tester) async {
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.ensureVisible(
|
||||||
|
find.byKey(const Key('eq-save-preset-action')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('eq-save-preset-action')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const Key('eq-save-preset-name-field')),
|
||||||
|
' ',
|
||||||
|
);
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const Key('eq-save-preset-confirm-button')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(AlertDialog), findsOneWidget);
|
||||||
|
expect(estado.ecualizador.presetsPersonalizados, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import 'package:pluriwave/modelos/emisora.dart';
|
|||||||
import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart';
|
import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||||
|
import 'package:pluriwave/tema/pluriwave_theme.dart';
|
||||||
|
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
|
||||||
import 'package:pluriwave/widgets/pluri_wave_scaffold.dart';
|
import 'package:pluriwave/widgets/pluri_wave_scaffold.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -89,9 +91,7 @@ Future<void> _montarPantalla(
|
|||||||
navigator.push(
|
navigator.push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder:
|
builder:
|
||||||
(_) => PantallaAlarmaSonando(
|
(_) => PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single),
|
||||||
alarma: estadoAlarmas.alarmas.single,
|
|
||||||
),
|
|
||||||
fullscreenDialog: true,
|
fullscreenDialog: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -143,4 +143,38 @@ void main() {
|
|||||||
expect(find.text(l10n.stopAlarmAction), findsOneWidget);
|
expect(find.text(l10n.stopAlarmAction), findsOneWidget);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
group('WU11 — restyle a pantalla completa', () {
|
||||||
|
testWidgets(
|
||||||
|
'el tiempo gigante usa PluriWaveTypography.heroTime envuelto en '
|
||||||
|
'FittedBox(scaleDown)',
|
||||||
|
(tester) async {
|
||||||
|
await _montarPantalla(tester);
|
||||||
|
|
||||||
|
final heroFinder = find.byKey(const ValueKey('ringing-hero-time'));
|
||||||
|
expect(heroFinder, findsOneWidget);
|
||||||
|
final texto = tester.widget<Text>(heroFinder);
|
||||||
|
final contexto = tester.element(heroFinder);
|
||||||
|
expect(texto.style, contexto.pluriType.heroTime);
|
||||||
|
expect(
|
||||||
|
find.ancestor(of: heroFinder, matching: find.byType(FittedBox)),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'el fondo es arte difuminado a pantalla completa; el contenedor '
|
||||||
|
'glass-card anterior desaparece',
|
||||||
|
(tester) async {
|
||||||
|
await _montarPantalla(tester);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('ringing-background-art')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byType(PluriGlassSurface), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,8 +107,7 @@ Future<_Entorno> _montarPantalla(
|
|||||||
navigator.push(
|
navigator.push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder:
|
builder:
|
||||||
(_) =>
|
(_) => PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single),
|
||||||
PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single),
|
|
||||||
fullscreenDialog: true,
|
fullscreenDialog: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -124,29 +123,153 @@ void main() {
|
|||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
group('WU11 — 3 tiles de posponer fijos (3/5/10 min)', () {
|
||||||
'muestra botones de posponer 3/5/10 mas el personalizado (S2-R1-A/C)',
|
testWidgets(
|
||||||
(tester) async {
|
'siempre son exactamente 3 tiles fijos, incluso con un snoozeMinutos '
|
||||||
await _montarPantalla(tester, snoozeMinutos: 7);
|
'personalizado que no es 3/5/10 (S2-R1-A/C, restilizado)',
|
||||||
|
(tester) async {
|
||||||
|
// WU11 correction: the ringing screen's snooze row is no longer a
|
||||||
|
// variable-length Wrap that grows for a custom value — the mockup's
|
||||||
|
// "3 fixed tiles" replaces it. A custom snoozeMinutos (7 here, same
|
||||||
|
// fixture as before WU11) still configures the ALARM's own default
|
||||||
|
// elsewhere (the editor), but no longer grows a 4th tile on this
|
||||||
|
// screen specifically.
|
||||||
|
await _montarPantalla(tester, snoozeMinutos: 7);
|
||||||
|
|
||||||
expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget);
|
expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget);
|
||||||
expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget);
|
expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget);
|
||||||
expect(find.text(l10n.alarmSnoozeOptionLabel(7)), findsOneWidget);
|
expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget);
|
||||||
expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget);
|
expect(find.text(l10n.alarmSnoozeOptionLabel(7)), findsNothing);
|
||||||
expect(find.text(l10n.stopAlarmAction), findsOneWidget);
|
expect(find.text(l10n.stopAlarmAction), findsOneWidget);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'no duplica el boton cuando snoozeMinutos coincide con una opcion fija',
|
'sigue habiendo exactamente 3 tiles cuando snoozeMinutos coincide con '
|
||||||
(tester) async {
|
'una opcion fija',
|
||||||
await _montarPantalla(tester, snoozeMinutos: 5);
|
(tester) async {
|
||||||
|
await _montarPantalla(tester, snoozeMinutos: 5);
|
||||||
|
|
||||||
expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget);
|
expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget);
|
||||||
expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget);
|
expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget);
|
||||||
expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget);
|
expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'el tile que coincide con snoozeMinutos es el destacado (FilledButton); '
|
||||||
|
'los otros dos son OutlinedButton',
|
||||||
|
(tester) async {
|
||||||
|
await _montarPantalla(tester, snoozeMinutos: 5);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.text(l10n.alarmSnoozeOptionLabel(5)),
|
||||||
|
matching: find.byType(FilledButton),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.text(l10n.alarmSnoozeOptionLabel(3)),
|
||||||
|
matching: find.byType(OutlinedButton),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.text(l10n.alarmSnoozeOptionLabel(10)),
|
||||||
|
matching: find.byType(OutlinedButton),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'design decision: cuando snoozeMinutos no es 3/5/10, el destacado '
|
||||||
|
'por defecto es 10 (el valor "habitual" del mockup)',
|
||||||
|
(tester) async {
|
||||||
|
await _montarPantalla(tester, snoozeMinutos: 7);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.text(l10n.alarmSnoozeOptionLabel(10)),
|
||||||
|
matching: find.byType(FilledButton),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.text(l10n.alarmSnoozeOptionLabel(3)),
|
||||||
|
matching: find.byType(OutlinedButton),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.text(l10n.alarmSnoozeOptionLabel(5)),
|
||||||
|
matching: find.byType(OutlinedButton),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('WU11 — pildora de Detener a todo lo ancho', () {
|
||||||
|
testWidgets('el boton de Detener ocupa todo el ancho disponible', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _montarPantalla(tester);
|
||||||
|
|
||||||
|
// The test viewport is 1440 logical px wide (physicalSize / ratio set
|
||||||
|
// in `_montarPantalla`); a normal wrap-content button would be well
|
||||||
|
// under 300px. This is a "clearly full-bleed, not auto-sized"
|
||||||
|
// assertion rather than a pixel-perfect one — the exact horizontal
|
||||||
|
// padding is a cosmetic layout detail, not a contract.
|
||||||
|
final tamano = tester.getSize(
|
||||||
|
find.byKey(const ValueKey('ringing-stop-button')),
|
||||||
|
);
|
||||||
|
expect(tamano.width, greaterThan(1000));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('WU11 — estado estatico de subida de volumen (resolucion 4, sin '
|
||||||
|
'contador en vivo)', () {
|
||||||
|
testWidgets(
|
||||||
|
'con fadeInSegundos > 0 muestra la etiqueta estatica, sin sufijo '
|
||||||
|
'numerico',
|
||||||
|
(tester) async {
|
||||||
|
await _montarPantalla(tester, fadeInSegundos: 20);
|
||||||
|
|
||||||
|
final texto = tester.widget<Text>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const ValueKey('estado-subida-volumen')),
|
||||||
|
matching: find.byType(Text),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Exact-equality (not `contains`) is what proves there is no
|
||||||
|
// interpolated/changing suffix at all — `alarmVolumeRisingStatus`
|
||||||
|
// carries no ARB placeholder, so this can never silently grow a
|
||||||
|
// live counter later without a deliberate key change.
|
||||||
|
expect(texto.data, l10n.alarmVolumeRisingStatus);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'con fadeInSegundos == 0 (por defecto) no muestra la etiqueta en '
|
||||||
|
'absoluto',
|
||||||
|
(tester) async {
|
||||||
|
await _montarPantalla(tester);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('estado-subida-volumen')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'posponer 5 min pospone la alarma y cierra la pantalla (S2-R1-B)',
|
'posponer 5 min pospone la alarma y cierra la pantalla (S2-R1-B)',
|
||||||
@@ -196,19 +319,18 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets(
|
testWidgets('detener confirmado no muestra el banner de fallo (SS-3c)', (
|
||||||
'detener confirmado no muestra el banner de fallo (SS-3c)',
|
tester,
|
||||||
(tester) async {
|
) async {
|
||||||
final entorno = await _montarPantalla(tester);
|
final entorno = await _montarPantalla(tester);
|
||||||
|
|
||||||
await tester.tap(find.text(l10n.stopAlarmAction));
|
await tester.tap(find.text(l10n.stopAlarmAction));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
||||||
expect(find.text(l10n.alarmStopFailedMessage), findsNothing);
|
expect(find.text(l10n.alarmStopFailedMessage), findsNothing);
|
||||||
expect(entorno.android.detencionesActivas, isNotEmpty);
|
expect(entorno.android.detencionesActivas, isNotEmpty);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'forzar detencion: invocacion superpuesta es no-op y tras un fallo '
|
'forzar detencion: invocacion superpuesta es no-op y tras un fallo '
|
||||||
|
|||||||
@@ -242,6 +242,15 @@ void main() {
|
|||||||
(tester) async {
|
(tester) async {
|
||||||
await _abrirEditor(tester);
|
await _abrirEditor(tester);
|
||||||
|
|
||||||
|
// WU10 correction: the fallback-station field moved into the
|
||||||
|
// collapsed "Advanced" section (native-alarms delta — Alarm Editor
|
||||||
|
// Preserves Date, Fallback Station, and Sound Fields). It must be
|
||||||
|
// expanded first — a collapsed `ExpansionTile` does not build its
|
||||||
|
// children, so `find.byKey` would otherwise find nothing.
|
||||||
|
await tester.ensureVisible(find.text(l10n.alarmAdvancedSectionTitle));
|
||||||
|
await tester.tap(find.text(l10n.alarmAdvancedSectionTitle));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.ensureVisible(
|
await tester.ensureVisible(
|
||||||
find.byKey(const ValueKey('alarm-fallback-station-field')),
|
find.byKey(const ValueKey('alarm-fallback-station-field')),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:intl/date_symbol_data_local.dart';
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_radio.dart';
|
||||||
import 'package:pluriwave/l10n/formato_fechas.dart';
|
import 'package:pluriwave/l10n/formato_fechas.dart';
|
||||||
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
|
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||||
|
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
||||||
|
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../helpers/fakes.dart';
|
||||||
|
import '../helpers/fakes_alarmas.dart';
|
||||||
|
|
||||||
/// S5-R4: short dates must follow the active locale, not a hardcoded
|
/// S5-R4: short dates must follow the active locale, not a hardcoded
|
||||||
/// DD/MM/YYYY pattern.
|
/// DD/MM/YYYY pattern.
|
||||||
@@ -24,4 +36,181 @@ void main() {
|
|||||||
DateFormat.yMd('es').format(fecha),
|
DateFormat.yMd('es').format(fecha),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('WU10 — la seccion Avanzada del editor conserva fecha, respaldo y '
|
||||||
|
'sonido', () {
|
||||||
|
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<EstadoAlarmas> abrirEditorNuevo(WidgetTester tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1440, 3200);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final favoritos = FakeServicioFavoritos();
|
||||||
|
await favoritos.agregar(emisoraDemo(uuid: 'gamma', nombre: 'Gamma FM'));
|
||||||
|
final radio = EstadoRadio(
|
||||||
|
audio: FakeServicioAudio(),
|
||||||
|
favoritos: favoritos,
|
||||||
|
radio: FakeServicioRadio(),
|
||||||
|
servicioEcualizador: FakeServicioEcualizador(),
|
||||||
|
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||||
|
iniciarAutomaticamente: false,
|
||||||
|
);
|
||||||
|
addTearDown(radio.dispose);
|
||||||
|
await radio.cargarFavoritos();
|
||||||
|
|
||||||
|
final android = FakePuertoAlarmasAndroid();
|
||||||
|
final estadoAlarmas = EstadoAlarmas(
|
||||||
|
servicio: ServicioAlarmas(reloj: DateTime.now),
|
||||||
|
android: android,
|
||||||
|
iniciarAutomaticamente: false,
|
||||||
|
);
|
||||||
|
addTearDown(estadoAlarmas.dispose);
|
||||||
|
addTearDown(android.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
|
||||||
|
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: const Locale('es'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const Scaffold(body: PantallaAlarmas()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text(l10n.createAlarmAction));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
return estadoAlarmas;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> expandirAvanzado(WidgetTester tester) async {
|
||||||
|
await tester.ensureVisible(find.text(l10n.alarmAdvancedSectionTitle));
|
||||||
|
await tester.tap(find.text(l10n.alarmAdvancedSectionTitle));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'los circulos de dia de la semana son siempre visibles, incluso '
|
||||||
|
'cuando el tipo es Una vez (por defecto en una alarma nueva)',
|
||||||
|
(tester) async {
|
||||||
|
await abrirEditorNuevo(tester);
|
||||||
|
|
||||||
|
expect(find.text(l10n.weekdayShortMonday), findsOneWidget);
|
||||||
|
expect(find.text(l10n.weekdayShortSunday), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'permite crear una alarma de fecha unica desde la seccion Avanzada '
|
||||||
|
'(colapsada por defecto)',
|
||||||
|
(tester) async {
|
||||||
|
final estadoAlarmas = await abrirEditorNuevo(tester);
|
||||||
|
|
||||||
|
// A brand-new alarm already defaults to "one time" — the date field
|
||||||
|
// is reachable as soon as Advanced is expanded, no mode switch
|
||||||
|
// needed first.
|
||||||
|
await expandirAvanzado(tester);
|
||||||
|
|
||||||
|
await tester.ensureVisible(find.text(l10n.dateField));
|
||||||
|
await tester.tap(find.text(l10n.dateField));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final okLabel =
|
||||||
|
MaterialLocalizations.of(
|
||||||
|
tester.element(find.text(l10n.dateField)),
|
||||||
|
).okButtonLabel;
|
||||||
|
await tester.tap(find.text(okLabel));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.ensureVisible(find.text(l10n.saveAlarmAction));
|
||||||
|
await tester.tap(find.text(l10n.saveAlarmAction));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final alarma = estadoAlarmas.alarmas.single;
|
||||||
|
expect(alarma.tipoProgramacion, TipoProgramacionAlarma.unica);
|
||||||
|
expect(alarma.fechaUnica, isNotNull);
|
||||||
|
expect(alarma.diasSemana, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'el selector de emisora de respaldo sigue alcanzable desde Avanzado '
|
||||||
|
'y persiste (S2-R9)',
|
||||||
|
(tester) async {
|
||||||
|
final estadoAlarmas = await abrirEditorNuevo(tester);
|
||||||
|
|
||||||
|
await expandirAvanzado(tester);
|
||||||
|
|
||||||
|
await tester.ensureVisible(
|
||||||
|
find.byKey(const ValueKey('alarm-fallback-station-field')),
|
||||||
|
);
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const ValueKey('alarm-fallback-station-field')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Scoped to the just-opened picker sheet's list: the primary
|
||||||
|
// station field auto-selects the sole registered favorite too
|
||||||
|
// (`EstadoRadio.emisoraPreferida` falls back to the first
|
||||||
|
// favorite), so an unscoped `find.text('Gamma FM')` would match
|
||||||
|
// twice — once there, once in this sheet.
|
||||||
|
final lista = find.byType(ListView).last;
|
||||||
|
expect(
|
||||||
|
find.descendant(of: lista, matching: find.text('Gamma FM')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(of: lista, matching: find.text('Gamma FM')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.ensureVisible(find.text(l10n.saveAlarmAction));
|
||||||
|
await tester.tap(find.text(l10n.saveAlarmAction));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final alarma = estadoAlarmas.alarmas.single;
|
||||||
|
expect(alarma.emisoraFallback?.nombre, 'Gamma FM');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'el dropdown de sonido interno sigue alcanzable desde Avanzado y '
|
||||||
|
'persiste',
|
||||||
|
(tester) async {
|
||||||
|
final estadoAlarmas = await abrirEditorNuevo(tester);
|
||||||
|
|
||||||
|
await expandirAvanzado(tester);
|
||||||
|
|
||||||
|
await tester.ensureVisible(
|
||||||
|
find.byType(DropdownButtonFormField<SonidoInternoAlarma>),
|
||||||
|
);
|
||||||
|
await tester.tap(
|
||||||
|
find.byType(DropdownButtonFormField<SonidoInternoAlarma>),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text(l10n.soundSoftBell).last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.ensureVisible(find.text(l10n.saveAlarmAction));
|
||||||
|
await tester.tap(find.text(l10n.saveAlarmAction));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final alarma = estadoAlarmas.alarmas.single;
|
||||||
|
expect(alarma.sonidoInterno, SonidoInternoAlarma.campanaSuave);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_ecualizador.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_radio.dart';
|
||||||
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
|
import 'package:pluriwave/modelos/emisora.dart';
|
||||||
|
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||||
|
import 'package:pluriwave/pantallas/pantalla_reproductor.dart';
|
||||||
|
import 'package:pluriwave/widgets/ecualizador_widget.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../helpers/fakes.dart';
|
||||||
|
import '../helpers/fakes_alarmas.dart';
|
||||||
|
|
||||||
|
/// `pantalla_reproductor.dart` (907 lines) had ZERO test coverage before
|
||||||
|
/// this commit — a gap first surfaced during WU5 (no test in this codebase
|
||||||
|
/// had ever exercised `androidAudioSessionIdStream` against a bare
|
||||||
|
/// `FakeServicioAudio` until the Escuchar hero wired `VisualizadorAudio` to
|
||||||
|
/// it). Strict TDD requires coverage BEFORE restructuring this screen, not
|
||||||
|
/// restructuring first and backfilling after — see the two groups below:
|
||||||
|
///
|
||||||
|
/// - `Characterization (pre-WU14 baseline)`: written and run GREEN against
|
||||||
|
/// the screen's CURRENT, unmodified structure (its own commit, before
|
||||||
|
/// this file's restructure). These pin the state-mutation CONTRACTS that
|
||||||
|
/// must survive WU14 unchanged, even though the WIDGETS that trigger them
|
||||||
|
/// move (favorite leaves the AppBar, EQ toggle is replaced by the
|
||||||
|
/// per-station EQ sheet, the always-expanded recording panel and the
|
||||||
|
/// standalone sleep-timer button both become tool-tray tiles).
|
||||||
|
/// - `WU14 — tool tray, square art, EQ sheet reuse`: the NEW target
|
||||||
|
/// structure's RED tests, satisfied by the restructure itself.
|
||||||
|
///
|
||||||
|
/// Two pre-existing bugs surfaced by writing this coverage (both fixed as
|
||||||
|
/// part of this WU, since neither can be worked around from the test side):
|
||||||
|
/// 1. `initState` called `estado.reproducir(...)` directly, which notifies
|
||||||
|
/// `EstadoRadio` listeners SYNCHRONOUSLY before its first `await` (no
|
||||||
|
/// active recording to stop) — threw "setState() or markNeedsBuild()
|
||||||
|
/// called during build" the instant this screen mounted against a fresh
|
||||||
|
/// Provider tree. Fixed via `addPostFrameCallback`.
|
||||||
|
/// 2. The body `Column` has no scrollable ancestor and overflows the
|
||||||
|
/// default 800x600 test viewport (and would overflow on a short real
|
||||||
|
/// device too) — worked around here via the same `physicalSize`
|
||||||
|
/// override `pantalla_alarma_sonando_test.dart` already established,
|
||||||
|
/// which does not require a production change to test against.
|
||||||
|
///
|
||||||
|
/// A third, environment-specific quirk (not a production bug — the same
|
||||||
|
/// `showModalBottomSheet` renders correctly in production and its dialog
|
||||||
|
/// TITLE is always found by these tests, confirming the sheet opens):
|
||||||
|
/// `tester.tap()` by widget position against an `ActionChip` or
|
||||||
|
/// `FilledButton` inside this screen's non-scroll-controlled bottom sheets
|
||||||
|
/// intermittently resolves an offset outside the test viewport regardless
|
||||||
|
/// of viewport size or `disableAnimations`. Every such action inside a
|
||||||
|
/// bottom sheet is invoked directly via its own `onPressed` callback
|
||||||
|
/// instead of `tester.tap()` —
|
||||||
|
/// this only bypasses hit-test positioning, not the actual production
|
||||||
|
/// callback wiring under test.
|
||||||
|
///
|
||||||
|
/// `VisualizadorAudio` starts a repeating `AnimationController` once
|
||||||
|
/// playback reaches "reproduciendo" (WU5's documented hazard) — `initState`
|
||||||
|
/// here calls `estado.reproducir(...)` unconditionally, so EVERY test in
|
||||||
|
/// this file reaches "reproduciendo" almost immediately. `disableAnimations:
|
||||||
|
/// true` (set below) keeps `flutter_animate`'s entrance-animation delays
|
||||||
|
/// from leaving a pending `Timer` at test end; every pump is still bounded
|
||||||
|
/// (`pump()` / `pump(Duration(...))`), never `pumpAndSettle()`.
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
final emisora = emisoraDemo(uuid: 'demo-uuid', nombre: 'Radio Demo');
|
||||||
|
|
||||||
|
EstadoRadio crearEstado({
|
||||||
|
FakeServicioGrabacionRadioActivable? grabacion,
|
||||||
|
List<Emisora> favoritosIniciales = const [],
|
||||||
|
Map<String, PresetEcualizador>? porEmisora,
|
||||||
|
}) {
|
||||||
|
final favoritos = FakeServicioFavoritos();
|
||||||
|
for (final e in favoritosIniciales) {
|
||||||
|
unawaited(favoritos.agregar(e));
|
||||||
|
}
|
||||||
|
return EstadoRadio(
|
||||||
|
audio: FakeServicioAudio(),
|
||||||
|
favoritos: favoritos,
|
||||||
|
radio: FakeServicioRadio(),
|
||||||
|
servicioEcualizador: FakeServicioEcualizador(porEmisora: porEmisora),
|
||||||
|
servicioGrabacion: grabacion ?? FakeServicioGrabacionRadioInactiva(),
|
||||||
|
iniciarAutomaticamente: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildScreen(
|
||||||
|
EstadoRadio estado, {
|
||||||
|
Emisora? estacion,
|
||||||
|
Future<void> Function(String)? compartir,
|
||||||
|
}) {
|
||||||
|
return MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
||||||
|
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
||||||
|
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: const Locale('en'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
builder:
|
||||||
|
(context, child) => MediaQuery(
|
||||||
|
data: MediaQuery.of(context).copyWith(disableAnimations: true),
|
||||||
|
child: child!,
|
||||||
|
),
|
||||||
|
home: PantallaReproductor(
|
||||||
|
emisora: estacion ?? emisora,
|
||||||
|
compartir: compartir,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default 800x600 test viewport is shorter than this screen's
|
||||||
|
/// non-scrolling content (never caught before, zero prior coverage) —
|
||||||
|
/// same fix `pantalla_alarma_sonando_test.dart` already established for
|
||||||
|
/// another full-bleed hero screen.
|
||||||
|
Future<void> montarPantalla(
|
||||||
|
WidgetTester tester,
|
||||||
|
EstadoRadio estado, {
|
||||||
|
Emisora? estacion,
|
||||||
|
Future<void> Function(String)? compartir,
|
||||||
|
}) async {
|
||||||
|
tester.view.physicalSize = const Size(1440, 3200);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildScreen(estado, estacion: estacion, compartir: compartir),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invokes an `ActionChip`'s own `onPressed` directly, bypassing
|
||||||
|
/// hit-testing — see the file-level doc comment for why.
|
||||||
|
Future<void> presionarActionChip(WidgetTester tester, String label) async {
|
||||||
|
final chip = tester.widget<ActionChip>(
|
||||||
|
find.widgetWithText(ActionChip, label),
|
||||||
|
);
|
||||||
|
chip.onPressed?.call();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same idea for a `FilledButton` inside a bottom sheet.
|
||||||
|
Future<void> presionarFilledButton(WidgetTester tester, String label) async {
|
||||||
|
final boton = tester.widget<FilledButton>(
|
||||||
|
find.widgetWithText(FilledButton, label),
|
||||||
|
);
|
||||||
|
boton.onPressed?.call();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
group('Characterization (pre-WU14 baseline)', () {
|
||||||
|
testWidgets('opening the screen starts playback for the given station', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
|
||||||
|
expect(estado.emisoraActual?.uuid, equals(emisora.uuid));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'opening the screen for the ALREADY-active station does not restart playback',
|
||||||
|
(tester) async {
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
await estado.reproducir(emisora);
|
||||||
|
final llamadasPrevias =
|
||||||
|
(estado.audio as FakeServicioAudio).emisorasReproducidas.length;
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(estado.audio as FakeServicioAudio).emisorasReproducidas.length,
|
||||||
|
equals(llamadasPrevias),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('tapping the primary button while playing pauses playback', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
expect(estado.audio.estaSonando, isTrue);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.pause_rounded));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(estado.audio.estaSonando, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping stop calls detenerReproduccion', (tester) async {
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
expect(estado.audio.estaSonando, isTrue);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.stop_rounded));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// detenerReproduccion() stops playback but does NOT clear
|
||||||
|
// emisoraActual (EstadoRadio.emisoraActual falls back to
|
||||||
|
// _emisoraSeleccionada, which stays set so the screen keeps showing
|
||||||
|
// the last selected station in its "stopped" state) — estaSonando is
|
||||||
|
// the correct signal for "stop actually happened".
|
||||||
|
expect(estado.audio.estaSonando, isFalse);
|
||||||
|
expect(estado.emisoraActual?.uuid, equals(emisora.uuid));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping favorite toggles the station favorite status', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isFalse);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.favorite_outline_rounded));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'starting an indefinite recording calls EstadoGrabacion.iniciar with no duration',
|
||||||
|
(tester) async {
|
||||||
|
final grabacionFake = FakeServicioGrabacionRadioActivable();
|
||||||
|
final estado = crearEstado(grabacion: grabacionFake);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
|
||||||
|
// Tool-tray "Grabar" tile opens the (relocated, unchanged)
|
||||||
|
// `_GrabacionWidget` status card first — its OWN "Record" button
|
||||||
|
// (a FilledButton, scoped to disambiguate from the tile's identical
|
||||||
|
// label behind it) then opens the duration-picker sheet.
|
||||||
|
await tester.tap(find.text('Record'));
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await presionarFilledButton(tester, 'Record');
|
||||||
|
await presionarActionChip(tester, 'Indefinite');
|
||||||
|
|
||||||
|
expect(grabacionFake.ultimaEmisoraIniciada?.uuid, equals(emisora.uuid));
|
||||||
|
expect(grabacionFake.duracionesIniciadas, equals([null]));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'starting a custom-duration recording validates and calls iniciar with that duration',
|
||||||
|
(tester) async {
|
||||||
|
final grabacionFake = FakeServicioGrabacionRadioActivable();
|
||||||
|
final estado = crearEstado(grabacion: grabacionFake);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Record'));
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await presionarFilledButton(tester, 'Record');
|
||||||
|
await presionarActionChip(tester, 'Custom');
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Minutes'),
|
||||||
|
'5',
|
||||||
|
);
|
||||||
|
// The trigger button behind the dialog is ALSO labelled "Record" —
|
||||||
|
// scope to the dialog's own confirm button specifically.
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(AlertDialog),
|
||||||
|
matching: find.text('Record'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
grabacionFake.duracionesIniciadas,
|
||||||
|
equals([const Duration(minutes: 5)]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('starting the sleep timer via a duration chip', (tester) async {
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
expect(estado.timer.activo, isFalse);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Sleep timer'));
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await presionarActionChip(tester, '15 min');
|
||||||
|
|
||||||
|
expect(estado.timer.activo, isTrue);
|
||||||
|
|
||||||
|
// ServicioTimer starts a real Timer.periodic(1s) — flutter_test's
|
||||||
|
// pending-timer check runs before addTearDown(estado.dispose) below,
|
||||||
|
// so it must be cancelled here, inside the test body, not left to
|
||||||
|
// teardown.
|
||||||
|
estado.cancelarTimer();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group(
|
||||||
|
'WU14 — square art, single subtitle, quality row, tool tray, EQ sheet reuse',
|
||||||
|
() {
|
||||||
|
testWidgets(
|
||||||
|
'the hero art is square (ClipRRect), not circular (no ClipOval)',
|
||||||
|
(tester) async {
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
|
||||||
|
final arte = find.byKey(const Key('player-hero-art'));
|
||||||
|
expect(arte, findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.descendant(of: arte, matching: find.byType(ClipRRect)),
|
||||||
|
findsWidgets,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.descendant(of: arte, matching: find.byType(ClipOval)),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('favorite lives in the transport row, not the AppBar', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
|
||||||
|
final favorito = find.byIcon(Icons.favorite_outline_rounded);
|
||||||
|
expect(favorito, findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.ancestor(of: favorito, matching: find.byType(AppBar)),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'a single subtitle line replaces the old separate info chips',
|
||||||
|
(tester) async {
|
||||||
|
const estacion = Emisora(
|
||||||
|
uuid: 'demo-uuid',
|
||||||
|
nombre: 'Radio Demo',
|
||||||
|
url: 'https://stream.demo/radio',
|
||||||
|
pais: 'Argentina',
|
||||||
|
idioma: 'Español',
|
||||||
|
codec: 'MP3',
|
||||||
|
bitrate: 128,
|
||||||
|
);
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado, estacion: estacion);
|
||||||
|
|
||||||
|
expect(find.byType(Chip), findsNothing);
|
||||||
|
expect(find.byKey(const Key('player-subtitle-line')), findsOneWidget);
|
||||||
|
expect(find.text('Argentina · Español'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'the quality row shows codec/bitrate; Change reconnects the current stream',
|
||||||
|
(tester) async {
|
||||||
|
const estacion = Emisora(
|
||||||
|
uuid: 'demo-uuid',
|
||||||
|
nombre: 'Radio Demo',
|
||||||
|
url: 'https://stream.demo/radio',
|
||||||
|
codec: 'MP3',
|
||||||
|
bitrate: 128,
|
||||||
|
);
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado, estacion: estacion);
|
||||||
|
final llamadasPrevias =
|
||||||
|
(estado.audio as FakeServicioAudio).emisorasReproducidas.length;
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('player-quality-row')), findsOneWidget);
|
||||||
|
expect(find.textContaining('MP3'), findsOneWidget);
|
||||||
|
expect(find.text('Change'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const Key('player-quality-change-action')),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(estado.audio as FakeServicioAudio).emisorasReproducidas.length,
|
||||||
|
greaterThan(llamadasPrevias),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'exactly 4 tool-tray tiles render: EQ propio, Grabar, sleep timer, Compartir',
|
||||||
|
(tester) async {
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('player-tool-eq')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-tool-record')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-tool-sleep')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-tool-share')), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'tapping Compartir invokes the injected share callback with the station name and url',
|
||||||
|
(tester) async {
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
String? compartido;
|
||||||
|
|
||||||
|
await montarPantalla(
|
||||||
|
tester,
|
||||||
|
estado,
|
||||||
|
compartir: (texto) async => compartido = texto,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-tool-share')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(compartido, contains(emisora.nombre));
|
||||||
|
expect(compartido, contains(emisora.url));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'tapping EQ propio opens a sheet reusing EcualizadorWidget by exact runtime type',
|
||||||
|
(tester) async {
|
||||||
|
final estado = crearEstado(
|
||||||
|
porEmisora: {'demo-uuid': PresetEcualizador.rock},
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
await estado.ecualizador.cargarPersistido();
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-tool-eq')));
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
|
||||||
|
final editores = tester.widgetList(find.byType(EcualizadorWidget));
|
||||||
|
expect(editores, hasLength(1));
|
||||||
|
expect(editores.single.runtimeType, equals(EcualizadorWidget));
|
||||||
|
expect(find.byType(Slider), findsNWidgets(5));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'the per-station EQ sheet is bound to the resolved preset and round-trips a change',
|
||||||
|
(tester) async {
|
||||||
|
final estado = crearEstado(
|
||||||
|
porEmisora: {'demo-uuid': PresetEcualizador.rock},
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
await estado.ecualizador.cargarPersistido();
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
await tester.tap(find.byKey(const Key('player-tool-eq')));
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
|
||||||
|
final primerSlider = tester.widget<Slider>(find.byType(Slider).first);
|
||||||
|
expect(
|
||||||
|
primerSlider.value,
|
||||||
|
equals(PresetEcualizador.rock.bandas.first),
|
||||||
|
);
|
||||||
|
|
||||||
|
primerSlider.onChanged?.call(4.0);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
estado.ecualizador.presetsPorEmisora['demo-uuid']?.bandas.first,
|
||||||
|
equals(4.0),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'no second EQ editor file exists — the sheet and Settings share the one EcualizadorWidget class',
|
||||||
|
(tester) async {
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
await tester.tap(find.byKey(const Key('player-tool-eq')));
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
|
||||||
|
// A structural regression guard: if a future change introduced a
|
||||||
|
// parallel editor widget, this assertion (exact type, not "a
|
||||||
|
// widget that looks like an equalizer") would catch it.
|
||||||
|
expect(
|
||||||
|
find.byWidgetPredicate((w) => w.runtimeType == EcualizadorWidget),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||||
|
import 'package:pluriwave/servicios/servicio_presets_personalizados.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// WU13 task 13.2 — persistence for user-named custom EQ presets.
|
||||||
|
///
|
||||||
|
/// Design ADR-5 hazard box: this lives in its OWN file and OWN
|
||||||
|
/// SharedPreferences key (`eq_custom_presets_v1`), deliberately separate
|
||||||
|
/// from `ServicioEcualizador` — that file has an empty-`git diff` success
|
||||||
|
/// criterion for this change, so persistence for custom presets must never
|
||||||
|
/// be added there.
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listar returns an empty list when nothing was saved yet', () async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final servicio = ServicioPresetsPersonalizados(prefs: prefs);
|
||||||
|
|
||||||
|
expect(await servicio.listar(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guardar then listar round-trips a named custom preset', () async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final servicio = ServicioPresetsPersonalizados(prefs: prefs);
|
||||||
|
// Not `const`: PresetEcualizador's constructor asserts `bandas.length
|
||||||
|
// == 5`, and `List.length` is not constant-foldable in an assert here
|
||||||
|
// (same const-eval limitation the branch already hit for
|
||||||
|
// `DateTime(...)` fixtures in WU9) — the model's own static presets
|
||||||
|
// (e.g. `PresetEcualizador.flat`) are declared `final`, not `const`,
|
||||||
|
// for the same reason.
|
||||||
|
final preset = PresetEcualizador(
|
||||||
|
nombre: 'Mi preset',
|
||||||
|
bandas: [1.0, -2.0, 3.0, -4.0, 5.0],
|
||||||
|
);
|
||||||
|
|
||||||
|
await servicio.guardar(preset);
|
||||||
|
final guardados = await servicio.listar();
|
||||||
|
|
||||||
|
expect(guardados, hasLength(1));
|
||||||
|
expect(guardados.single, equals(preset));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guardar persists under its OWN SharedPreferences key', () async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final servicio = ServicioPresetsPersonalizados(prefs: prefs);
|
||||||
|
final preset = PresetEcualizador(nombre: 'Otro', bandas: [0, 0, 0, 0, 0]);
|
||||||
|
|
||||||
|
await servicio.guardar(preset);
|
||||||
|
|
||||||
|
expect(prefs.getString('eq_custom_presets_v1'), isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guardar with a repeated name replaces the previous entry', () async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final servicio = ServicioPresetsPersonalizados(prefs: prefs);
|
||||||
|
final v1 = PresetEcualizador(nombre: 'Mismo', bandas: [1, 1, 1, 1, 1]);
|
||||||
|
final v2 = PresetEcualizador(nombre: 'Mismo', bandas: [2, 2, 2, 2, 2]);
|
||||||
|
|
||||||
|
await servicio.guardar(v1);
|
||||||
|
await servicio.guardar(v2);
|
||||||
|
final guardados = await servicio.listar();
|
||||||
|
|
||||||
|
expect(guardados, hasLength(1));
|
||||||
|
expect(guardados.single.bandas, equals(v2.bandas));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guardar preserves insertion order across multiple presets', () async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final servicio = ServicioPresetsPersonalizados(prefs: prefs);
|
||||||
|
final primero = PresetEcualizador(
|
||||||
|
nombre: 'Primero',
|
||||||
|
bandas: [0, 0, 0, 0, 0],
|
||||||
|
);
|
||||||
|
final segundo = PresetEcualizador(
|
||||||
|
nombre: 'Segundo',
|
||||||
|
bandas: [0, 0, 0, 0, 0],
|
||||||
|
);
|
||||||
|
|
||||||
|
await servicio.guardar(primero);
|
||||||
|
await servicio.guardar(segundo);
|
||||||
|
final guardados = await servicio.listar();
|
||||||
|
|
||||||
|
expect(guardados.map((p) => p.nombre).toList(), ['Primero', 'Segundo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eliminar removes the named preset only', () async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final servicio = ServicioPresetsPersonalizados(prefs: prefs);
|
||||||
|
final conservar = PresetEcualizador(
|
||||||
|
nombre: 'Conservar',
|
||||||
|
bandas: [0, 0, 0, 0, 0],
|
||||||
|
);
|
||||||
|
final borrar = PresetEcualizador(nombre: 'Borrar', bandas: [0, 0, 0, 0, 0]);
|
||||||
|
await servicio.guardar(conservar);
|
||||||
|
await servicio.guardar(borrar);
|
||||||
|
|
||||||
|
await servicio.eliminar('Borrar');
|
||||||
|
final guardados = await servicio.listar();
|
||||||
|
|
||||||
|
expect(guardados, hasLength(1));
|
||||||
|
expect(guardados.single.nombre, equals('Conservar'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eliminar on an unknown name is a safe no-op', () async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final servicio = ServicioPresetsPersonalizados(prefs: prefs);
|
||||||
|
|
||||||
|
await servicio.eliminar('no existe');
|
||||||
|
|
||||||
|
expect(await servicio.listar(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a corrupt top-level payload degrades to an empty list, never throws',
|
||||||
|
() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('eq_custom_presets_v1', 'not valid json{{{');
|
||||||
|
final servicio = ServicioPresetsPersonalizados(prefs: prefs);
|
||||||
|
|
||||||
|
await expectLater(servicio.listar(), completion(isEmpty));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
|
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||||
|
import 'package:pluriwave/widgets/ecualizador_widget.dart';
|
||||||
|
|
||||||
|
/// WU13 task 13.1 — first-class regression guard (design ADR-5, spec
|
||||||
|
/// `eq-custom-presets` "Five-Band Equalizer"): the equalizer widget must
|
||||||
|
/// always render exactly 5 vertical sliders. Band count is device-reported
|
||||||
|
/// via `just_audio`'s `AndroidEqualizer` (spike, Engram id 2498), not
|
||||||
|
/// app-chosen — any future change rendering 7 sliders is rejected on sight.
|
||||||
|
///
|
||||||
|
/// **Correction found at apply time**: `tasks.md`'s WU13 Verify command
|
||||||
|
/// names this file as if it already existed ("Modified tests:
|
||||||
|
/// ecualizador_widget_test.dart"). No such file existed before this commit
|
||||||
|
/// (`ecualizador_widget.dart` had zero test coverage) — created new instead.
|
||||||
|
void main() {
|
||||||
|
Widget buildWidget({
|
||||||
|
PresetEcualizador? preset,
|
||||||
|
bool habilitado = true,
|
||||||
|
void Function(PresetEcualizador)? onCambio,
|
||||||
|
}) {
|
||||||
|
return MaterialApp(
|
||||||
|
locale: const Locale('en'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: EcualizadorWidget(
|
||||||
|
preset: preset ?? PresetEcualizador.flat,
|
||||||
|
habilitado: habilitado,
|
||||||
|
onCambio: onCambio ?? (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('renders exactly 5 vertical sliders, one per band', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(buildWidget());
|
||||||
|
|
||||||
|
expect(find.byType(Slider), findsNWidgets(5));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders exactly 5 sliders regardless of the preset selected', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// Regression guard restated: a future change proposing 7 sliders (the
|
||||||
|
// rejected mockup band count) must fail this test regardless of which
|
||||||
|
// fixed preset is active.
|
||||||
|
await tester.pumpWidget(buildWidget(preset: PresetEcualizador.jazz));
|
||||||
|
|
||||||
|
expect(find.byType(Slider), findsNWidgets(5));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('dragging a slider reports the updated band back via onCambio', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
PresetEcualizador? reportado;
|
||||||
|
await tester.pumpWidget(buildWidget(onCambio: (p) => reportado = p));
|
||||||
|
|
||||||
|
final slider = tester.widget<Slider>(find.byType(Slider).first);
|
||||||
|
slider.onChanged?.call(6.0);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(reportado, isNotNull);
|
||||||
|
expect(reportado!.bandas.length, 5);
|
||||||
|
expect(reportado!.bandas.first, 6.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'habilitado: false disables every slider (greyed, non-interactive)',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(buildWidget(habilitado: false));
|
||||||
|
|
||||||
|
final sliders = tester.widgetList<Slider>(find.byType(Slider));
|
||||||
|
expect(sliders, hasLength(5));
|
||||||
|
for (final slider in sliders) {
|
||||||
|
expect(slider.onChanged, isNull);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('habilitado: true (default) keeps every slider interactive', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(buildWidget());
|
||||||
|
|
||||||
|
final sliders = tester.widgetList<Slider>(find.byType(Slider));
|
||||||
|
expect(sliders, hasLength(5));
|
||||||
|
for (final slider in sliders) {
|
||||||
|
expect(slider.onChanged, isNotNull);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
|
import 'package:pluriwave/widgets/editor_hora_inline.dart';
|
||||||
|
|
||||||
|
/// WU10: standalone tests for the inline HH:MM editor, independent of
|
||||||
|
/// `_EditorAlarmaSheet` (the sheet only wires `value`/`onChanged`).
|
||||||
|
Future<void> _montar(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required TimeOfDay inicial,
|
||||||
|
ValueChanged<TimeOfDay>? onChanged,
|
||||||
|
}) async {
|
||||||
|
var valor = inicial;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
locale: const Locale('es'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
return EditorHoraInline(
|
||||||
|
value: valor,
|
||||||
|
onChanged: (nuevo) {
|
||||||
|
setState(() => valor = nuevo);
|
||||||
|
onChanged?.call(nuevo);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _keyHora = ValueKey('editor-hora-inline-hora');
|
||||||
|
const _keyMinuto = ValueKey('editor-hora-inline-minuto');
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('muestra la hora inicial formateada HH:MM', (tester) async {
|
||||||
|
await _montar(tester, inicial: const TimeOfDay(hour: 7, minute: 5));
|
||||||
|
|
||||||
|
expect(find.text('07'), findsOneWidget);
|
||||||
|
expect(find.text('05'), findsOneWidget);
|
||||||
|
expect(find.text(':'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tocar el segmento de hora incrementa solo la hora', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
TimeOfDay? recibido;
|
||||||
|
await _montar(
|
||||||
|
tester,
|
||||||
|
inicial: const TimeOfDay(hour: 10, minute: 30),
|
||||||
|
onChanged: (nuevo) => recibido = nuevo,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(_keyHora));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(recibido, const TimeOfDay(hour: 11, minute: 30));
|
||||||
|
expect(find.text('11'), findsOneWidget);
|
||||||
|
expect(find.text('30'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tocar el segmento de minuto incrementa solo el minuto', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
TimeOfDay? recibido;
|
||||||
|
await _montar(
|
||||||
|
tester,
|
||||||
|
inicial: const TimeOfDay(hour: 10, minute: 30),
|
||||||
|
onChanged: (nuevo) => recibido = nuevo,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(_keyMinuto));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(recibido, const TimeOfDay(hour: 10, minute: 31));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'incrementar el minuto en 23:59 envuelve a 00:00 (hora y minuto)',
|
||||||
|
(tester) async {
|
||||||
|
TimeOfDay? recibido;
|
||||||
|
await _montar(
|
||||||
|
tester,
|
||||||
|
inicial: const TimeOfDay(hour: 23, minute: 59),
|
||||||
|
onChanged: (nuevo) => recibido = nuevo,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(_keyMinuto));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(recibido, const TimeOfDay(hour: 0, minute: 0));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('incrementar la hora en 23 envuelve a 0 sin tocar el minuto', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
TimeOfDay? recibido;
|
||||||
|
await _montar(
|
||||||
|
tester,
|
||||||
|
inicial: const TimeOfDay(hour: 23, minute: 45),
|
||||||
|
onChanged: (nuevo) => recibido = nuevo,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(_keyHora));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(recibido, const TimeOfDay(hour: 0, minute: 45));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'arrastrar hacia arriba en el minuto lo incrementa; hacia abajo lo '
|
||||||
|
'decrementa',
|
||||||
|
(tester) async {
|
||||||
|
TimeOfDay? recibido;
|
||||||
|
await _montar(
|
||||||
|
tester,
|
||||||
|
inicial: const TimeOfDay(hour: 10, minute: 30),
|
||||||
|
onChanged: (nuevo) => recibido = nuevo,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.drag(find.byKey(_keyMinuto), const Offset(0, -96));
|
||||||
|
await tester.pump();
|
||||||
|
expect(recibido, isNotNull);
|
||||||
|
expect(recibido!.hour, 10);
|
||||||
|
expect(recibido!.minute, greaterThan(30));
|
||||||
|
final minutoTrasSubir = recibido!.minute;
|
||||||
|
|
||||||
|
await tester.drag(find.byKey(_keyMinuto), Offset(0, 96));
|
||||||
|
await tester.pump();
|
||||||
|
expect(recibido!.minute, lessThan(minutoTrasSubir));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('expone acciones de accesibilidad de incrementar/decrementar con '
|
||||||
|
'etiqueta y valor', (tester) async {
|
||||||
|
final semantics = tester.ensureSemantics();
|
||||||
|
final l10n = await AppLocalizations.delegate.load(const Locale('es'));
|
||||||
|
await _montar(tester, inicial: const TimeOfDay(hour: 6, minute: 8));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tester.getSemantics(find.byKey(_keyHora)),
|
||||||
|
matchesSemantics(
|
||||||
|
label: l10n.alarmInlineHourLabel,
|
||||||
|
value: '06',
|
||||||
|
hasIncreaseAction: true,
|
||||||
|
hasDecreaseAction: true,
|
||||||
|
hasTapAction: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.getSemantics(find.byKey(_keyMinuto)),
|
||||||
|
matchesSemantics(
|
||||||
|
label: l10n.alarmInlineMinuteLabel,
|
||||||
|
value: '08',
|
||||||
|
hasIncreaseAction: true,
|
||||||
|
hasDecreaseAction: true,
|
||||||
|
hasTapAction: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
semantics.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user