refactor(ajustes): split Settings AUDIO/EMISORAS into pushed detail screens
Moves the AUDIO group (Ecualizador, Salida de audio, Temporizador de
sueno) and the EMISORAS group (Grupos de favoritos, Emisora preferida,
Emisoras personalizadas, Orden de listas) out of pantalla_ajustes.dart
into 7 new lib/pantallas/ajustes/*.dart screens, each wrapped in
PluriPushScaffold. The root now reaches them through FilaAjuste rows
under two new GrupoAjustes cards (lib/pantallas/ajustes/widgets/
fila_ajuste.dart), per design ADR-3.
Verbatim-move rule applied throughout: only each section's panel header
(icon + title, sometimes a status chip) was removed, since the pushed
screen's own 56px header now carries the title. Two sections whose
header row carried a real action (Temporizador de sueno's "Add",
Grupos de favoritos' "Add list", Emisoras personalizadas' "Add") kept
that action in the body instead of dropping it.
size:exception (move-only diff, pre-recorded at design/tasks time):
34 files, ~4250 changed lines excluding the 13 auto-regenerated l10n
files (~90 more lines there) - higher than the 800-1000 estimate
because that estimate covered the 7 production screens but not the
matching 7 new test files (task 3a.2), one of which relocates ~10
pre-existing device-management test cases verbatim. Business logic is
untouched; app.dart's import of pantalla_ajustes.dart is unchanged.
Correction to tasks.md 3a.1/3a.8: those two lines describe the combined
WU3a+WU3b end state ("4 grouped nav lists", "<400 lines"), matching
design ADR-3's own aggregate blast-radius note - not a WU3a-only claim.
This commit converts only the 2 groups that are WU3a's job; the root
is 788 lines with 5 sections (Grabaciones, Musica local, Idioma,
Backup, Info) still inline, reachable, and unchanged, pending WU3b.
Two new ARB keys (settingsGroupAudioTitle, settingsGroupStationsTitle),
en/es only per the WU1 precedent - all 7 detail-screen titles reuse
existing keys. Discovered and worked around, without touching app
code: Directory.systemTemp hangs real dart:io writes in this sandbox,
and pumpAndSettle() cannot settle while a screen shows an indeterminate
CircularProgressIndicator - both are test-only concerns, documented
inline where hit.
Tests: 560 -> 579 (32 in this commit's scope, net +19 after retiring
13 relocated cases from the old combined pantalla_ajustes_test.dart).
flutter analyze: unchanged at 1 pre-existing info. git diff is empty
for navegacion_auto.dart, servicio_ecualizador.dart and
servicio_audio.dart; pantalla_alarma_sonando_dismiss_guard_test.dart
untouched.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../estado/estado_ecualizador.dart';
|
||||
import '../../estado/estado_radio.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../widgets/ecualizador_widget.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// AUDIO group · "Ecualizador" (design ADR-3). Body moved verbatim from the
|
||||
/// former `_SeccionEcualizador` in `pantalla_ajustes.dart` — only the panel
|
||||
/// header row (icon + title + status chip) was removed, since
|
||||
/// [PluriPushScaffold] now carries the title and the very next row already
|
||||
/// shows the same active/disabled state.
|
||||
///
|
||||
/// This body is a placeholder pending WU13's restyle (ADR-3's own component
|
||||
/// inventory: "body rewritten by WU13").
|
||||
class PantallaAjustesEcualizador extends StatelessWidget {
|
||||
const PantallaAjustesEcualizador({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => PluriPushScaffold(
|
||||
title: AppLocalizations.of(context).equalizerTitle,
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoEcualizador()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _CuerpoEcualizador extends StatelessWidget {
|
||||
const _CuerpoEcualizador();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// EQ state comes from EstadoEcualizador (S4-R1/S4-R5); EstadoRadio is
|
||||
// only consulted for the current station + favorite flag.
|
||||
return Consumer2<EstadoRadio, EstadoEcualizador>(
|
||||
builder: (ctx, estado, eq, _) {
|
||||
final disponible = eq.disponible;
|
||||
final l10n = AppLocalizations.of(ctx);
|
||||
final emisoraActual = estado.emisoraActual;
|
||||
final mostrarModoPorEmisora =
|
||||
emisoraActual != null && estado.emisoraActualEsFavorita;
|
||||
final usandoEqPropio = eq.emisoraActualTienePresetPropio;
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.equalizerEnable),
|
||||
subtitle: Text(
|
||||
disponible
|
||||
? l10n.equalizerRealtimeSubtitle
|
||||
: l10n.equalizerPendingSubtitle,
|
||||
),
|
||||
value: eq.activo,
|
||||
onChanged: eq.cambiarActivo,
|
||||
),
|
||||
if (mostrarModoPorEmisora) ...[
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.equalizerPerStationTitle),
|
||||
subtitle: Text(
|
||||
usandoEqPropio
|
||||
? l10n.equalizerPerStationActive(emisoraActual.nombre)
|
||||
: l10n.equalizerPerStationMain(emisoraActual.nombre),
|
||||
),
|
||||
value: usandoEqPropio,
|
||||
onChanged:
|
||||
(usarPropio) =>
|
||||
eq.cambiarModoEmisoraActual(usarPropio: usarPropio),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
PresetsEcualizadorWidget(
|
||||
presetActual: eq.presetActual,
|
||||
onSeleccionar: (p) => eq.cambiarPreset(p),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
EcualizadorWidget(
|
||||
preset: eq.presetActual,
|
||||
onCambio: (p) => eq.cambiarPreset(p),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../estado/estado_radio.dart';
|
||||
import '../../l10n/display_names.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../modelos/emisora.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// EMISORAS group · "Emisora preferida" (design ADR-3). Body moved verbatim
|
||||
/// from the former `_SeccionEmisoraPreferida` in `pantalla_ajustes.dart` —
|
||||
/// only the panel header row (icon + title) was removed, since
|
||||
/// [PluriPushScaffold] now carries the title.
|
||||
class PantallaAjustesEmisoraPreferida extends StatelessWidget {
|
||||
const PantallaAjustesEmisoraPreferida({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => PluriPushScaffold(
|
||||
title: AppLocalizations.of(context).preferredStationTitle,
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoEmisoraPreferida()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _CuerpoEmisoraPreferida extends StatelessWidget {
|
||||
const _CuerpoEmisoraPreferida();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
// S4-R5: scoped selects over identity-memoized getters.
|
||||
final favoritas = context.select<EstadoRadio, List<Emisora>>(
|
||||
(e) => e.listaFavoritos,
|
||||
);
|
||||
final disponibles = context.select<EstadoRadio, List<Emisora>>(
|
||||
(e) => e.emisorasDisponiblesPreferencia,
|
||||
);
|
||||
final preferida = context.select<EstadoRadio, Emisora?>(
|
||||
(e) => e.emisoraPreferida,
|
||||
);
|
||||
final opciones = _opciones(favoritas, disponibles, preferida);
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.preferredStationDescription,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (opciones.isEmpty)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.info_outline_rounded),
|
||||
title: Text(l10n.preferredStationNoStationsTitle),
|
||||
subtitle: Text(l10n.preferredStationNoStationsSubtitle),
|
||||
)
|
||||
else
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: preferida?.uuid,
|
||||
decoration: InputDecoration(
|
||||
labelText:
|
||||
favoritas.isEmpty
|
||||
? l10n.preferredStationAutomaticFallback
|
||||
: l10n.preferredStationDefaultFavorite,
|
||||
),
|
||||
items: [
|
||||
for (final emisora in opciones)
|
||||
DropdownMenuItem<String>(
|
||||
value: emisora.uuid,
|
||||
child: Text(
|
||||
localizedStationName(l10n, emisora.nombre),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (uuid) async {
|
||||
final seleccion = opciones.firstWhere((e) => e.uuid == uuid);
|
||||
await context.read<EstadoRadio>().cambiarEmisoraPreferida(
|
||||
seleccion,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (preferida != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
favoritas.any((e) => e.uuid == preferida.uuid)
|
||||
? l10n.preferredStationCurrent(
|
||||
localizedStationName(l10n, preferida.nombre),
|
||||
)
|
||||
: l10n.preferredStationAutoUsing(
|
||||
localizedStationName(l10n, preferida.nombre),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilledButton.tonalIcon(
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: Text(l10n.preferredStationPlay),
|
||||
onPressed:
|
||||
() =>
|
||||
context
|
||||
.read<EstadoRadio>()
|
||||
.reproducirEmisoraPreferida(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Emisora> _opciones(
|
||||
List<Emisora> favoritas,
|
||||
List<Emisora> disponibles,
|
||||
Emisora? preferida,
|
||||
) {
|
||||
final base = favoritas.isNotEmpty ? favoritas : disponibles;
|
||||
final mapa = <String, Emisora>{
|
||||
for (final emisora in base) emisora.uuid: emisora,
|
||||
};
|
||||
if (preferida != null) {
|
||||
mapa[preferida.uuid] = preferida;
|
||||
}
|
||||
return mapa.values.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../estado/estado_radio.dart';
|
||||
import '../../l10n/display_names.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../modelos/emisora.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// EMISORAS group · "Emisoras personalizadas" (design ADR-3). Body moved
|
||||
/// verbatim from the former `_SeccionEmisoras` + `_FormularioEmisora` in
|
||||
/// `pantalla_ajustes.dart` — the panel header's icon, title and spacer were
|
||||
/// removed (the pushed screen's title now carries them); the "Add" action,
|
||||
/// being a real capability rather than decorative chrome, stays in the body,
|
||||
/// right-aligned.
|
||||
class PantallaAjustesEmisorasPersonalizadas extends StatelessWidget {
|
||||
const PantallaAjustesEmisorasPersonalizadas({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => PluriPushScaffold(
|
||||
title: AppLocalizations.of(context).customStationsTitle,
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoEmisorasPersonalizadas()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _CuerpoEmisorasPersonalizadas extends StatelessWidget {
|
||||
const _CuerpoEmisorasPersonalizadas();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// S4-R5: scoped select — rebuilds only when the custom list changes.
|
||||
final custom = context.select<EstadoRadio, List<Emisora>>(
|
||||
(e) => e.emisorasCustom,
|
||||
);
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(AppLocalizations.of(context).customStationsAdd),
|
||||
onPressed: () => _mostrarFormularioAnadir(context),
|
||||
),
|
||||
),
|
||||
if (custom.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).customStationsEmpty,
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
for (final emisora in custom)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.radio_rounded),
|
||||
title: Text(
|
||||
localizedStationName(
|
||||
AppLocalizations.of(context),
|
||||
emisora.nombre,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
emisora.url,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
tooltip: AppLocalizations.of(context).playAction,
|
||||
onPressed:
|
||||
() => context.read<EstadoRadio>().reproducir(emisora),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
tooltip: AppLocalizations.of(context).deleteAction,
|
||||
onPressed:
|
||||
() => context
|
||||
.read<EstadoRadio>()
|
||||
.eliminarEmitoraCustom(emisora.uuid),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _mostrarFormularioAnadir(BuildContext context) async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
builder: (ctx) => const _FormularioEmisora(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormularioEmisora extends StatefulWidget {
|
||||
const _FormularioEmisora();
|
||||
|
||||
@override
|
||||
State<_FormularioEmisora> createState() => _FormularioEmisoraState();
|
||||
}
|
||||
|
||||
class _FormularioEmisoraState extends State<_FormularioEmisora> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nombreCtrl = TextEditingController();
|
||||
final _urlCtrl = TextEditingController();
|
||||
final _paisCtrl = TextEditingController();
|
||||
bool _guardando = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nombreCtrl.dispose();
|
||||
_urlCtrl.dispose();
|
||||
_paisCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _guardar() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _guardando = true);
|
||||
|
||||
final emisora = Emisora(
|
||||
uuid: const Uuid().v4(),
|
||||
nombre: _nombreCtrl.text.trim(),
|
||||
url: _urlCtrl.text.trim(),
|
||||
pais: _paisCtrl.text.trim().isEmpty ? null : _paisCtrl.text.trim(),
|
||||
);
|
||||
|
||||
await context.read<EstadoRadio>().agregarEmitoraCustom(emisora);
|
||||
if (mounted) Navigator.pop(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.horizontal + bottom,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l10n.addStationTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _nombreCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context).stationNameLabel,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
validator:
|
||||
(v) =>
|
||||
v == null || v.trim().isEmpty
|
||||
? AppLocalizations.of(context).requiredField
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _urlCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context).streamUrlLabel,
|
||||
hintText: AppLocalizations.of(context).streamUrlHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) {
|
||||
return l10n.requiredField;
|
||||
}
|
||||
final uri = Uri.tryParse(v.trim());
|
||||
if (uri == null || !uri.hasScheme) return l10n.invalidUrl;
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _paisCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.countryOptionalLabel,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: _guardando ? null : _guardar,
|
||||
child:
|
||||
_guardando
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(AppLocalizations.of(context).saveStation),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../estado/estado_radio.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../modelos/grupo_favoritos.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// EMISORAS group · "Grupos de favoritos" (design ADR-3). Body moved
|
||||
/// verbatim from the former `_SeccionGruposFavoritos` in
|
||||
/// `pantalla_ajustes.dart` — the panel header's icon and title were removed
|
||||
/// (the pushed screen's title now carries them); the "Add list" action,
|
||||
/// being a real capability rather than decorative chrome, stays in the body,
|
||||
/// right-aligned.
|
||||
class PantallaAjustesGruposFavoritos extends StatelessWidget {
|
||||
const PantallaAjustesGruposFavoritos({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => PluriPushScaffold(
|
||||
title: AppLocalizations.of(context).favoriteGroupsTitle,
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoGruposFavoritos()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _CuerpoGruposFavoritos extends StatelessWidget {
|
||||
const _CuerpoGruposFavoritos();
|
||||
|
||||
Future<void> _editarGrupo(
|
||||
BuildContext context, [
|
||||
GrupoFavoritos? grupo,
|
||||
]) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final controller = TextEditingController(text: grupo?.nombre ?? '');
|
||||
final nombre = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (ctx) {
|
||||
final bottom = MediaQuery.viewInsetsOf(ctx).bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
grupo == null
|
||||
? l10n.favoriteGroupsAdd
|
||||
: l10n.favoriteGroupsEdit,
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
maxLength: 28,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.favoriteGroupsNameLabel,
|
||||
helperText: l10n.favoriteGroupsNameTooLong,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.save_rounded),
|
||||
label: Text(AppLocalizations.of(ctx).saveQuickAccessButton),
|
||||
onPressed: () {
|
||||
final value = controller.text.trim();
|
||||
if (value.isEmpty || value.length > 28) return;
|
||||
Navigator.pop(ctx, value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
controller.dispose();
|
||||
if (nombre == null || !context.mounted) return;
|
||||
final estado = context.read<EstadoRadio>();
|
||||
if (grupo == null) {
|
||||
await estado.crearGrupoFavoritos(nombre);
|
||||
} else {
|
||||
await estado.renombrarGrupoFavoritos(grupo.id, nombre);
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
grupo == null
|
||||
? l10n.favoriteGroupsCreated
|
||||
: l10n.favoriteGroupsUpdated,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _eliminarGrupo(
|
||||
BuildContext context,
|
||||
GrupoFavoritos grupo,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
await context.read<EstadoRadio>().eliminarGrupoFavoritos(grupo.id);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.favoriteGroupsDeleted)));
|
||||
}
|
||||
|
||||
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
|
||||
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
// S4-R5: scoped select — rebuilds only when the groups list changes.
|
||||
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
|
||||
(e) => e.gruposFavoritos,
|
||||
);
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.favoriteGroupsDescription),
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(l10n.favoriteGroupsAdd),
|
||||
onPressed: () => _editarGrupo(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
for (final grupo in grupos)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(
|
||||
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
|
||||
),
|
||||
title: Text(_nombreVisible(l10n, grupo)),
|
||||
subtitle:
|
||||
grupo.esSinAsignar
|
||||
? Text(l10n.favoriteGroupsProtectedHint)
|
||||
: null,
|
||||
trailing:
|
||||
grupo.esSinAsignar
|
||||
? null
|
||||
: Wrap(
|
||||
spacing: 4,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.favoriteGroupsEdit,
|
||||
icon: const Icon(Icons.edit_rounded),
|
||||
onPressed: () => _editarGrupo(context, grupo),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.favoriteGroupsDelete,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
onPressed: () => _eliminarGrupo(context, grupo),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../estado/estado_radio.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// EMISORAS group · "Orden de listas" (design ADR-3). Body moved verbatim
|
||||
/// from the former `_SeccionOrdenListas` in `pantalla_ajustes.dart` — only
|
||||
/// the panel header row (icon + title) was removed, since
|
||||
/// [PluriPushScaffold] now carries the title.
|
||||
class PantallaAjustesOrdenListas extends StatelessWidget {
|
||||
const PantallaAjustesOrdenListas({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => PluriPushScaffold(
|
||||
title: AppLocalizations.of(context).stationOrderTitle,
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoOrdenListas()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _CuerpoOrdenListas extends StatelessWidget {
|
||||
const _CuerpoOrdenListas();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// S4-R5: scoped select — rebuilds only when the ordering changes.
|
||||
final orden = context.select<EstadoRadio, OrdenEmisoras>(
|
||||
(e) => e.ordenListas,
|
||||
);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SegmentedButton<OrdenEmisoras>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: OrdenEmisoras.nombre,
|
||||
icon: const Icon(Icons.sort_by_alpha_rounded),
|
||||
label: Text(l10n.stationOrderByName),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: OrdenEmisoras.calidad,
|
||||
icon: const Icon(Icons.hd_rounded),
|
||||
label: Text(l10n.stationOrderByQuality),
|
||||
),
|
||||
],
|
||||
selected: {orden},
|
||||
onSelectionChanged: (value) {
|
||||
context.read<EstadoRadio>().cambiarOrdenListas(value.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.stationOrderScopeDescription,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../estado/estado_ecualizador.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../modelos/preset_ecualizador.dart';
|
||||
import '../../widgets/ecualizador_widget.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// AUDIO group · "Salida de audio" (design ADR-3). Body moved verbatim from
|
||||
/// the former `_SeccionEcualizadorAvanzado` + `_FilaDispositivo` +
|
||||
/// `_DialogoEdicionDispositivo` in `pantalla_ajustes.dart` — only the panel
|
||||
/// header row (icon + title) was removed, since [PluriPushScaffold] now
|
||||
/// carries the title. The visible title text is unchanged
|
||||
/// ("Advanced Equalization Options" / `advancedEqSectionTitle`) — the file
|
||||
/// name reflects the design's AUDIO row label ("Salida de audio"), not new
|
||||
/// UI copy.
|
||||
class PantallaAjustesSalidaAudio extends StatelessWidget {
|
||||
const PantallaAjustesSalidaAudio({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => PluriPushScaffold(
|
||||
title: AppLocalizations.of(context).advancedEqSectionTitle,
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoSalidaAudio()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Always shows the feature toggle so the user can discover it. When the
|
||||
/// toggle is OFF, the device list is completely absent (not just invisible),
|
||||
/// matching the spec scenario "Settings section is absent when toggle is
|
||||
/// off".
|
||||
class _CuerpoSalidaAudio extends StatefulWidget {
|
||||
const _CuerpoSalidaAudio();
|
||||
|
||||
@override
|
||||
State<_CuerpoSalidaAudio> createState() => _CuerpoSalidaAudioState();
|
||||
}
|
||||
|
||||
class _CuerpoSalidaAudioState extends State<_CuerpoSalidaAudio> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Fix "stale green dot": refresh the active-device indicator with a
|
||||
// fresh native query the moment this section becomes visible, instead of
|
||||
// trusting the last event that happened to arrive (no-op when the
|
||||
// multi-device toggle is off).
|
||||
unawaited(context.read<EstadoEcualizador>().refrescarDispositivoActual());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final eq = context.watch<EstadoEcualizador>();
|
||||
final multiDeviceEnabled = eq.eqMultiDeviceEnabled;
|
||||
final presetsDispositivo = eq.presetsDispositivo;
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// The toggle uses GestureDetector + custom row instead of
|
||||
// SwitchListTile to avoid Material ink assertion inside
|
||||
// PluriGlassSurface's DecoratedBox. The visual result is identical
|
||||
// to SwitchListTile.
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => _alternarMultiDevice(eq, !multiDeviceEnabled),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.advancedEqEnableToggle,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l10n.advancedEqEnableToggleSubtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Switch.adaptive(
|
||||
value: multiDeviceEnabled,
|
||||
onChanged:
|
||||
(habilitado) => _alternarMultiDevice(eq, habilitado),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (multiDeviceEnabled) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.advancedEqKnownDevicesTitle,
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (presetsDispositivo.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
l10n.advancedEqKnownDevicesEmpty,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
)
|
||||
else
|
||||
for (final entry in presetsDispositivo.entries)
|
||||
_FilaDispositivo(deviceId: entry.key, preset: entry.value),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Toggles the multi-device EQ feature and, when turning it ON, requests
|
||||
/// `BLUETOOTH_CONNECT` at this point-of-intent (bt-device-identity ADR-1)
|
||||
/// so BT devices report their real MAC instead of the OS placeholder.
|
||||
/// Fire-and-forget: neither call blocks the toggle UI on its result.
|
||||
void _alternarMultiDevice(EstadoEcualizador eq, bool habilitado) {
|
||||
unawaited(eq.cambiarMultiDeviceEnabled(habilitado));
|
||||
if (habilitado) {
|
||||
unawaited(eq.solicitarPermisoBluetooth());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single device row in the known-devices list.
|
||||
///
|
||||
/// Shows a connection indicator (green dot) when [deviceId] matches the
|
||||
/// currently active device. Tapping the edit icon opens
|
||||
/// [_DialogoEdicionDispositivo].
|
||||
class _FilaDispositivo extends StatelessWidget {
|
||||
const _FilaDispositivo({required this.deviceId, required this.preset});
|
||||
|
||||
final String deviceId;
|
||||
final PresetEcualizador preset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final eq = context.watch<EstadoEcualizador>();
|
||||
final isActive = eq.dispositivoActualId == deviceId;
|
||||
final displayName = _nombreLegible(
|
||||
deviceId,
|
||||
eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId)),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
// The dot marks where audio is coming out RIGHT NOW, which is not the
|
||||
// same as "paired" or "connected" — it needs a label, both for screen
|
||||
// readers and for anyone wondering what a bare green dot means.
|
||||
if (isActive)
|
||||
Tooltip(
|
||||
message: l10n.eqDeviceActiveOutput,
|
||||
child: Icon(
|
||||
Icons.circle,
|
||||
size: 10,
|
||||
color: Colors.green,
|
||||
semanticLabel: l10n.eqDeviceActiveOutput,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.headphones_rounded, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
l10n.advancedEqDevicePresetLabel(preset.nombre),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_rounded, size: 20),
|
||||
tooltip: l10n.eqDeviceEditTitle,
|
||||
onPressed: () => _abrirModal(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Turns a device id the user never named into something readable.
|
||||
///
|
||||
/// [nombreVisible] falls back to the raw id when neither a custom name nor a
|
||||
/// platform name is known — which is the normal case for a Bluetooth device
|
||||
/// that is not currently connected, since platform names are cached in memory
|
||||
/// only. Showing `bt_a2dp:AA:BB:CC:DD:EE:FF` tells the user nothing, so keep
|
||||
/// the transport plus the tail of the address, which is what distinguishes
|
||||
/// two otherwise identical rows.
|
||||
static String _nombreLegible(String deviceId, String nombreVisible) {
|
||||
if (nombreVisible != deviceId) return nombreVisible;
|
||||
|
||||
final separador = deviceId.indexOf(':');
|
||||
if (separador == -1) return deviceId;
|
||||
final transporte = deviceId.substring(0, separador);
|
||||
final resto = deviceId.substring(separador + 1);
|
||||
final etiqueta = switch (transporte) {
|
||||
'bt_a2dp' => 'Bluetooth',
|
||||
'usb_headset' => 'USB',
|
||||
_ => transporte,
|
||||
};
|
||||
final cola = resto.split(':').where((p) => p.isNotEmpty).toList();
|
||||
if (cola.isEmpty) return etiqueta;
|
||||
final sufijo =
|
||||
cola.length >= 2 ? cola.sublist(cola.length - 2).join(':') : cola.last;
|
||||
return '$etiqueta · $sufijo';
|
||||
}
|
||||
|
||||
Future<void> _abrirModal(BuildContext context) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder:
|
||||
(ctx) =>
|
||||
_DialogoEdicionDispositivo(deviceId: deviceId, preset: preset),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom sheet for editing a device's custom name and EQ preset.
|
||||
class _DialogoEdicionDispositivo extends StatefulWidget {
|
||||
const _DialogoEdicionDispositivo({
|
||||
required this.deviceId,
|
||||
required this.preset,
|
||||
});
|
||||
|
||||
final String deviceId;
|
||||
final PresetEcualizador preset;
|
||||
|
||||
@override
|
||||
State<_DialogoEdicionDispositivo> createState() =>
|
||||
_DialogoEdicionDispositivoState();
|
||||
}
|
||||
|
||||
class _DialogoEdicionDispositivoState
|
||||
extends State<_DialogoEdicionDispositivo> {
|
||||
late final TextEditingController _nombreCtrl;
|
||||
late PresetEcualizador _presetActual;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final eq = context.read<EstadoEcualizador>();
|
||||
final displayName = eq.nombreVisible(
|
||||
widget.deviceId,
|
||||
eq.nombrePlataforma(widget.deviceId),
|
||||
);
|
||||
_nombreCtrl = TextEditingController(text: displayName);
|
||||
_presetActual = widget.preset;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nombreCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _guardar() async {
|
||||
final eq = context.read<EstadoEcualizador>();
|
||||
await eq.renombrarDispositivo(widget.deviceId, _nombreCtrl.text);
|
||||
if (_presetActual != widget.preset) {
|
||||
await eq.guardarPresetDispositivo(widget.deviceId, _presetActual);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> _eliminar() async {
|
||||
final eq = context.read<EstadoEcualizador>();
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final nombre =
|
||||
_nombreCtrl.text.trim().isEmpty
|
||||
? widget.deviceId
|
||||
: _nombreCtrl.text.trim();
|
||||
|
||||
await eq.eliminarDispositivo(widget.deviceId);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.eqDeviceRemoved(nombre))),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.eqDeviceEditTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _nombreCtrl,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.eqDeviceNameLabel,
|
||||
hintText: l10n.eqDeviceNameHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
EcualizadorWidget(
|
||||
preset: _presetActual,
|
||||
onCambio: (p) => setState(() => _presetActual = p),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _guardar,
|
||||
icon: const Icon(Icons.save_rounded),
|
||||
label: Text(l10n.eqDeviceNameConfirm),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Lets the user clear stale or duplicate rows. The device comes
|
||||
// back on its next connection, so this is recoverable.
|
||||
OutlinedButton.icon(
|
||||
onPressed: _eliminar,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
label: Text(l10n.eqDeviceRemove),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../estado/estado_radio.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// AUDIO group · "Temporizador de sueño" (design ADR-3). Body moved verbatim
|
||||
/// from the former `_SeccionTimerSueno` in `pantalla_ajustes.dart` — the
|
||||
/// panel header's icon and title were removed (the pushed screen's title
|
||||
/// now carries them), and the "Add" action moved into the screen's app bar
|
||||
/// via [PluriPushScaffold.actions] since it is a real capability, not
|
||||
/// decorative header chrome.
|
||||
class PantallaAjustesTimerSueno extends StatelessWidget {
|
||||
const PantallaAjustesTimerSueno({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return PluriPushScaffold(
|
||||
title: l10n.timerSectionTitle,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
tooltip: l10n.timerSectionAdd,
|
||||
onPressed: () => _anadirPreset(context),
|
||||
),
|
||||
],
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoTimerSueno()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _anadirPreset(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
final duracion = await showModalBottomSheet<Duration>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const _FormularioDuracionTimer(),
|
||||
);
|
||||
if (duracion == null || !context.mounted) return;
|
||||
await context.read<EstadoRadio>().agregarTimerSuenoPreset(duracion);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'${l10n.saveQuickAccessButton}: ${_formatearDuracionTimer(l10n, duracion)}',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _CuerpoTimerSueno extends StatelessWidget {
|
||||
const _CuerpoTimerSueno();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
// S4-R5: scoped select — rebuilds only when the presets list changes.
|
||||
final presets = context.select<EstadoRadio, List<int>>(
|
||||
(e) => e.timerSuenoPresetsSegundos,
|
||||
);
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.timerSectionDescription,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final segundos in presets)
|
||||
InputChip(
|
||||
label: Text(
|
||||
_formatearDuracionTimer(l10n, Duration(seconds: segundos)),
|
||||
),
|
||||
onDeleted:
|
||||
presets.length <= 1
|
||||
? null
|
||||
: () => context
|
||||
.read<EstadoRadio>()
|
||||
.eliminarTimerSuenoPreset(segundos),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.restore_rounded),
|
||||
label: Text(l10n.timerSectionRestoreRecommended),
|
||||
onPressed:
|
||||
() =>
|
||||
context.read<EstadoRadio>().restaurarTimerSuenoPresets(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormularioDuracionTimer extends StatefulWidget {
|
||||
const _FormularioDuracionTimer();
|
||||
|
||||
@override
|
||||
State<_FormularioDuracionTimer> createState() =>
|
||||
_FormularioDuracionTimerState();
|
||||
}
|
||||
|
||||
class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
|
||||
final _horasCtrl = TextEditingController();
|
||||
final _minutosCtrl = TextEditingController(text: '15');
|
||||
final _segundosCtrl = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_horasCtrl.dispose();
|
||||
_minutosCtrl.dispose();
|
||||
_segundosCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0;
|
||||
|
||||
void _guardar() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
final duracion = Duration(
|
||||
hours: _leer(_horasCtrl),
|
||||
minutes: _leer(_minutosCtrl),
|
||||
seconds: _leer(_segundosCtrl),
|
||||
);
|
||||
if (duracion <= Duration.zero) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.durationGreaterThanZero)));
|
||||
return;
|
||||
}
|
||||
Navigator.pop(context, duracion);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(18, 0, 18, 18 + bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l10n.newQuickAccessTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _campo(_horasCtrl, l10n.hoursLabel)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _campo(_minutosCtrl, l10n.minutesLabel)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _campo(_segundosCtrl, l10n.secondsLabel)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.save_rounded),
|
||||
label: Text(l10n.saveQuickAccessButton),
|
||||
onPressed: _guardar,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _campo(TextEditingController controller, String label) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatearDuracionTimer(AppLocalizations l10n, Duration duracion) {
|
||||
final horas = duracion.inHours;
|
||||
final minutos = duracion.inMinutes.remainder(60);
|
||||
final segundos = duracion.inSeconds.remainder(60);
|
||||
if (horas > 0) {
|
||||
return l10n.durationHoursMinutesSeconds(
|
||||
horas,
|
||||
minutos.toString().padLeft(2, '0'),
|
||||
segundos.toString().padLeft(2, '0'),
|
||||
);
|
||||
}
|
||||
if (minutos > 0) {
|
||||
return segundos == 0
|
||||
? l10n.durationMinutesOnly(minutos)
|
||||
: l10n.durationMinutesSeconds(
|
||||
minutos,
|
||||
segundos.toString().padLeft(2, '0'),
|
||||
);
|
||||
}
|
||||
return l10n.durationSecondsOnly(segundos);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../tema/pluriwave_theme.dart';
|
||||
import '../../../widgets/pluri_glass_surface.dart';
|
||||
|
||||
/// Design ADR-3: the two nav-row primitives every Settings detail screen is
|
||||
/// reached through. [GrupoAjustes] is a single [PluriGlassSurface] card
|
||||
/// carrying a [PluriWaveTypography.eyebrowLabel] group heading and a list of
|
||||
/// [FilaAjuste] rows, each pushing its detail screen via
|
||||
/// `PluriPushScaffold.push`. Neither primitive owns any business logic or
|
||||
/// provider read — they are pure navigation chrome, which is what keeps the
|
||||
/// Settings root down to "grouped nav rows only".
|
||||
class GrupoAjustes extends StatelessWidget {
|
||||
const GrupoAjustes({super.key, required this.titulo, required this.filas});
|
||||
|
||||
/// Group heading, styled with [PluriWaveTypography.eyebrowLabel]. Authored
|
||||
/// already in its display form — this style never applies `toUpperCase()`.
|
||||
final String titulo;
|
||||
|
||||
final List<FilaAjuste> filas;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = context.pluriType;
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(titulo, style: type.eyebrowLabel),
|
||||
const SizedBox(height: 4),
|
||||
for (var i = 0; i < filas.length; i++) ...[
|
||||
if (i > 0) const Divider(height: 1),
|
||||
filas[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single Settings navigation row: icon, title, and a trailing chevron.
|
||||
/// Tapping it is the row's only behaviour — it carries no switches, sliders
|
||||
/// or text fields, which is what "zero inline controls" means at the root.
|
||||
class FilaAjuste extends StatelessWidget {
|
||||
const FilaAjuste({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.titulo,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String titulo;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = context.pluriType;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(icon),
|
||||
title: Text(titulo, style: type.cardTitle),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
+106
-1214
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user