On-device logcat from the latest test showed two defects the previous design created. The fade-in was gated on the station reaching `reproduciendo`, and the stream took 18.7 seconds to buffer: the ring sat frozen at 5% the whole time and the configured fade seconds only started counting afterwards. And the stream override was raised during pre-start, so the ExoPlayer AudioTrack spin-up — which runs at gain 1.0 for an instant before the player gain lands — blasted at the configured ring level, heard as "starts directly at the alarm volume". The ramp is now anchored at alarm time: it starts when the screen starts, buffering just joins it at the elapsed level, and the fade duration means seconds-from-alarm. _iniciarFadeIn is single-start so the handoff confirmation and fallback paths can no longer restart an in-progress ramp from 5%. The stream override moved from the app-side pre-start into the screen and is raised only when audio is actually about to flow (first `reproduciendo`, the already-playing branch, or right before the fallback WAV plays), so track spin-up happens under the user's original low volume and the blast is physically impossible. Exit teardown restores the device stream before resetting the player gain, removing the brief exit blip seen in the capture.
642 lines
22 KiB
Dart
642 lines
22 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'estado/estado_busqueda.dart';
|
|
import 'estado/estado_ecualizador.dart';
|
|
import 'estado/estado_grabacion.dart';
|
|
import 'estado/estado_radio.dart';
|
|
import 'estado/estado_alarmas.dart';
|
|
import 'estado/estado_idioma.dart';
|
|
import 'l10n/display_names.dart';
|
|
import 'l10n/gen/app_localizations.dart';
|
|
import 'modelos/alarma_musical.dart';
|
|
import 'pantallas/pantalla_alarmas.dart';
|
|
import 'pantallas/pantalla_alarma_sonando.dart';
|
|
import 'pantallas/pantalla_inicio.dart';
|
|
import 'pantallas/pantalla_buscar.dart';
|
|
import 'pantallas/pantalla_favoritos.dart';
|
|
import 'pantallas/pantalla_ajustes.dart';
|
|
import 'tema/pluriwave_theme.dart';
|
|
import 'widgets/pluri_bottom_navigation.dart';
|
|
import 'widgets/pluri_icon.dart';
|
|
import 'widgets/pluri_layout.dart';
|
|
import 'widgets/pluri_onboarding_dialog.dart';
|
|
import 'widgets/pluri_wave_scaffold.dart';
|
|
import 'package:pluriwave/widgets/mini_reproductor.dart';
|
|
import 'servicios/servicio_alarmas_android.dart';
|
|
import 'servicios/servicio_dispositivo_audio.dart';
|
|
|
|
class PluriWaveApp extends StatelessWidget {
|
|
const PluriWaveApp({super.key, this.prefs});
|
|
|
|
/// Single SharedPreferences instance resolved in main() (S3-R4) and
|
|
/// injected into every state/service.
|
|
final SharedPreferences? prefs;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider(
|
|
create:
|
|
(_) => EstadoRadio(
|
|
prefs: prefs,
|
|
dispositivoAudio: ServicioDispositivoAudioReal(),
|
|
),
|
|
),
|
|
// Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
|
|
// (they need its services and callbacks at construction); these
|
|
// providers only expose the instances, so they declare no dispose
|
|
// callback.
|
|
ListenableProvider<EstadoEcualizador>(
|
|
create: (context) => context.read<EstadoRadio>().ecualizador,
|
|
),
|
|
ListenableProvider<EstadoGrabacion>(
|
|
create: (context) => context.read<EstadoRadio>().grabacion,
|
|
),
|
|
ListenableProvider<EstadoBusqueda>(
|
|
create: (context) => context.read<EstadoRadio>().busqueda,
|
|
),
|
|
ChangeNotifierProvider(create: (_) => EstadoAlarmas(prefs: prefs)),
|
|
ChangeNotifierProvider(
|
|
create: (_) => EstadoIdioma(sharedPreferences: prefs),
|
|
),
|
|
],
|
|
child: Consumer<EstadoIdioma>(
|
|
builder:
|
|
(context, estadoIdioma, _) => MaterialApp(
|
|
title: 'PluriWave',
|
|
debugShowCheckedModeBanner: false,
|
|
theme: PluriWaveTheme.dark(),
|
|
darkTheme: PluriWaveTheme.dark(),
|
|
themeMode: ThemeMode.dark,
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
locale: estadoIdioma.localeSeleccionado,
|
|
home: const _PaginaPrincipal(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PaginaPrincipal extends StatefulWidget {
|
|
const _PaginaPrincipal();
|
|
|
|
@override
|
|
State<_PaginaPrincipal> createState() => _PaginaPrincipalState();
|
|
}
|
|
|
|
class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
|
static const _volumenInicialFadeInAlarmas = 0.05;
|
|
int _indice = 0;
|
|
StreamSubscription<String>? _errorSubscription;
|
|
StreamSubscription<EventoAlarmaAndroid>? _alarmaSubscription;
|
|
StreamSubscription<AlarmaMusical>? _alarmaVencidaSubscription;
|
|
EstadoRadio? _estadoSuscrito;
|
|
bool _alarmaInicialProcesada = false;
|
|
bool _alarmaSonandoActiva = false;
|
|
bool _onboardingInicialSolicitado = false;
|
|
String? _alarmaSonandoId;
|
|
Locale? _localeAlarmasConfigurado;
|
|
|
|
static const _paginas = [
|
|
PantallaInicio(),
|
|
PantallaBuscar(),
|
|
PantallaFavoritos(),
|
|
PantallaAlarmas(),
|
|
PantallaAjustes(),
|
|
];
|
|
|
|
List<PluriNavItem> _navItems(AppLocalizations l10n) => [
|
|
PluriNavItem(glyph: PluriIconGlyph.home, label: l10n.navHome),
|
|
PluriNavItem(glyph: PluriIconGlyph.search, label: l10n.navSearch),
|
|
PluriNavItem(glyph: PluriIconGlyph.favorites, label: l10n.navFavorites),
|
|
PluriNavItem(glyph: PluriIconGlyph.alarm, label: l10n.navAlarms),
|
|
PluriNavItem(glyph: PluriIconGlyph.settings, label: l10n.navSettings),
|
|
];
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
// S3-R3 / Decision 3.2: keep the alarm bridge l10n in sync, once per
|
|
// locale change (this hook re-runs when Localizations changes).
|
|
final locale = Localizations.localeOf(context);
|
|
if (_localeAlarmasConfigurado != locale) {
|
|
_localeAlarmasConfigurado = locale;
|
|
context.read<EstadoAlarmas>().configurarLocalizaciones(
|
|
AppLocalizations.of(context),
|
|
);
|
|
}
|
|
final estado = context.read<EstadoRadio>();
|
|
if (identical(_estadoSuscrito, estado) && _errorSubscription != null) {
|
|
return;
|
|
}
|
|
_errorSubscription?.cancel();
|
|
_estadoSuscrito = estado;
|
|
_errorSubscription = estado.errorStream.listen((msg) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(msg),
|
|
duration: const Duration(seconds: 3),
|
|
action: SnackBarAction(
|
|
label: AppLocalizations.of(context).actionOk,
|
|
onPressed: () {},
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
final alarmas = context.read<EstadoAlarmas>();
|
|
_alarmaSubscription ??= alarmas.android.eventosAlarma.listen((evento) {
|
|
if (!mounted) return;
|
|
_abrirAlarmaSonando(evento);
|
|
});
|
|
_alarmaVencidaSubscription ??= alarmas.alarmasVencidasStream.listen((
|
|
alarma,
|
|
) {
|
|
if (!mounted) return;
|
|
_abrirAlarmaDirecta(alarma);
|
|
});
|
|
if (!_alarmaInicialProcesada) {
|
|
_alarmaInicialProcesada = true;
|
|
unawaited(_procesarAlarmaInicial(alarmas));
|
|
}
|
|
if (!_onboardingInicialSolicitado) {
|
|
_onboardingInicialSolicitado = true;
|
|
unawaited(_mostrarOnboardingInicial());
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_errorSubscription?.cancel();
|
|
_alarmaSubscription?.cancel();
|
|
_alarmaVencidaSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return PluriWaveScaffold(
|
|
appBar: AppBar(
|
|
title: Text(l10n.appTitle),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.bedtime_outlined),
|
|
tooltip: l10n.sleepTimer,
|
|
onPressed: () => _mostrarTimerDialog(context),
|
|
),
|
|
],
|
|
),
|
|
body: SafeArea(
|
|
top: false,
|
|
child: AnimatedSwitcher(
|
|
duration: context.pluriMotion.normal,
|
|
switchInCurve: Curves.easeOutCubic,
|
|
switchOutCurve: Curves.easeInCubic,
|
|
transitionBuilder:
|
|
(child, animation) => FadeTransition(
|
|
opacity: animation,
|
|
child: SlideTransition(
|
|
position: Tween<Offset>(
|
|
begin: const Offset(0.035, 0),
|
|
end: Offset.zero,
|
|
).animate(animation),
|
|
child: child,
|
|
),
|
|
),
|
|
child: KeyedSubtree(
|
|
key: ValueKey<int>(_indice),
|
|
child: _paginas[_indice],
|
|
),
|
|
),
|
|
),
|
|
bottomNavigationBar: SafeArea(
|
|
top: false,
|
|
minimum: const EdgeInsets.only(bottom: PluriLayout.compactGap),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const MiniReproductor(),
|
|
PluriBottomNavigation(
|
|
items: _navItems(l10n),
|
|
selectedIndex: _indice,
|
|
onSelected: (i) => setState(() => _indice = i),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _procesarAlarmaInicial(EstadoAlarmas alarmas) async {
|
|
final evento = await alarmas.android.obtenerEventoInicial();
|
|
if (evento != null && mounted) {
|
|
await _abrirAlarmaSonando(evento);
|
|
}
|
|
}
|
|
|
|
Future<void> _mostrarOnboardingInicial() async {
|
|
await Future<void>.delayed(const Duration(milliseconds: 900));
|
|
if (!mounted || _alarmaSonandoActiva) return;
|
|
await PluriOnboardingDialog.mostrarSiProcede(context);
|
|
}
|
|
|
|
Future<void> _abrirAlarmaSonando(EventoAlarmaAndroid evento) async {
|
|
if (evento.accion == EventoAlarmaAndroid.accionSnoozed) {
|
|
// EstadoAlarmas records native snoozes itself (Decision 2.1); there is
|
|
// nothing to open for this event.
|
|
return;
|
|
}
|
|
final estado = context.read<EstadoAlarmas>();
|
|
if (estado.alarmas.isEmpty) {
|
|
await estado.cargarPersistidasSinRecalcular();
|
|
}
|
|
AlarmaMusical? alarma;
|
|
for (final item in estado.alarmas) {
|
|
if (item.id == evento.alarmaId) {
|
|
alarma = item;
|
|
break;
|
|
}
|
|
}
|
|
if (alarma == null || !mounted) {
|
|
debugPrint(
|
|
'[PluriWave][alarmas] evento sin alarma persistida id=${evento.alarmaId} accion=${evento.accion}',
|
|
);
|
|
return;
|
|
}
|
|
if (evento.accion.endsWith('.SKIP_NEXT')) {
|
|
await estado.saltarProxima(alarma.id);
|
|
if (!mounted) return;
|
|
setState(() => _indice = 3);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
AppLocalizations.of(context).skipCurrentAlarmExecution(
|
|
localizedAlarmName(AppLocalizations.of(context), alarma.nombre),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (evento.accion.endsWith('.POSTPONE_NEXT')) {
|
|
final ejecucion =
|
|
evento.occurrenceAtMillis > 0
|
|
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
|
|
: alarma.proximaEjecucion ?? DateTime.now();
|
|
await estado.posponerProximaDesdePreaviso(
|
|
alarma,
|
|
evento.snoozeMinutes,
|
|
ejecucion,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() => _indice = 3);
|
|
// posponerProximaDesdePreaviso no longer throws on a native scheduling
|
|
// failure — it records the failure into EstadoAlarmas.error instead.
|
|
// Branch on it here so the user sees the real outcome instead of an
|
|
// always-success message.
|
|
final error = estado.error;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
error ??
|
|
AppLocalizations.of(context).alarmPostponedCurrentExecution,
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (evento.accion.endsWith('.PRE_NOTICE')) {
|
|
setState(() => _indice = 3);
|
|
return;
|
|
}
|
|
await _mostrarAlarmaSonando(alarma);
|
|
}
|
|
|
|
Future<void> _abrirAlarmaDirecta(AlarmaMusical alarma) async {
|
|
await _mostrarAlarmaSonando(alarma);
|
|
}
|
|
|
|
Future<void> _mostrarAlarmaSonando(AlarmaMusical alarma) async {
|
|
final alarmas = context.read<EstadoAlarmas>();
|
|
alarmas.marcarEjecucionGestionada(alarma);
|
|
|
|
if (_alarmaSonandoActiva) {
|
|
debugPrint(
|
|
'[PluriWave][alarmas] alarma ignorada porque ya hay una activa id=${alarma.id} activa=$_alarmaSonandoId',
|
|
);
|
|
// A duplicate delivery of the SAME ring's own fire event (the live
|
|
// eventosAlarma stream and the one-shot obtenerEventoInicial() read
|
|
// the same native event and can both reach here) must be a no-op:
|
|
// ocultarNotificacionAlarma -> dismissAlarmNotification unconditionally
|
|
// stops PluriWaveAlarmService for that id on the native side, which
|
|
// would tear down the currently-ringing service and undo the
|
|
// ring-scoped media-volume override long before the real handoff.
|
|
// Only hide the notification when a genuinely DIFFERENT alarm fired
|
|
// while this one is active (single-ring-at-a-time by design).
|
|
if (alarma.id != _alarmaSonandoId) {
|
|
await alarmas.android.ocultarNotificacionAlarma(alarma.id);
|
|
}
|
|
return;
|
|
}
|
|
|
|
_alarmaSonandoActiva = true;
|
|
_alarmaSonandoId = alarma.id;
|
|
|
|
try {
|
|
await _prearrancarAudioAlarma(alarma);
|
|
if (!mounted) return;
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder:
|
|
(_) => PantallaAlarmaSonando(
|
|
alarma: alarma,
|
|
audioPrearrancado: alarma.emisora != null,
|
|
),
|
|
fullscreenDialog: true,
|
|
),
|
|
);
|
|
} finally {
|
|
if (_alarmaSonandoId == alarma.id) {
|
|
_alarmaSonandoActiva = false;
|
|
_alarmaSonandoId = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _prearrancarAudioAlarma(AlarmaMusical alarma) async {
|
|
// The ring-scoped stream override is NOT raised here anymore: the ringing
|
|
// screen owns it and raises it only when audio is actually about to be
|
|
// audible (first `reproduciendo`, or right before the fallback WAV
|
|
// plays). Raising it during pre-start let the ExoPlayer track spin-up —
|
|
// which briefly runs at gain 1.0 — blast at the ring level; under the
|
|
// user's original stream volume that spin-up is inaudible.
|
|
final emisora = alarma.emisora;
|
|
if (emisora == null) return;
|
|
final radio = context.read<EstadoRadio>();
|
|
debugPrint(
|
|
'[PluriWave][alarmas] prearrancar emisora alarma id=${alarma.id} emisora=${emisora.nombre}',
|
|
);
|
|
await radio.audio.setVolumen(_volumenInicialFadeInAlarmas);
|
|
unawaited(radio.reproducir(emisora));
|
|
}
|
|
|
|
void _mostrarTimerDialog(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
showDragHandle: true,
|
|
builder:
|
|
(ctx) => Consumer<EstadoRadio>(
|
|
builder:
|
|
(ctx, estado, _) => SafeArea(
|
|
child: Padding(
|
|
padding: PluriLayout.sheetPadding,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
AppLocalizations.of(ctx).sleepTimer,
|
|
style: Theme.of(ctx).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: PluriLayout.sectionGap),
|
|
Text(
|
|
AppLocalizations.of(ctx).sleepTimerDescription,
|
|
style: Theme.of(ctx).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: PluriLayout.panelGap),
|
|
if (estado.timer.activo)
|
|
StreamBuilder<Duration>(
|
|
stream: estado.timer.tiempoRestanteStream,
|
|
builder: (ctx, snap) {
|
|
final restante =
|
|
snap.data ?? estado.timer.tiempoRestante;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
_formatearDuracionTimer(
|
|
AppLocalizations.of(ctx),
|
|
restante,
|
|
),
|
|
style:
|
|
Theme.of(ctx).textTheme.headlineMedium,
|
|
),
|
|
const SizedBox(
|
|
height: PluriLayout.compactGap,
|
|
),
|
|
FilledButton.tonal(
|
|
onPressed: () {
|
|
estado.cancelarTimer();
|
|
Navigator.pop(ctx);
|
|
},
|
|
child: Text(
|
|
AppLocalizations.of(ctx).cancelTimer,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
)
|
|
else
|
|
Wrap(
|
|
spacing: PluriLayout.compactGap,
|
|
runSpacing: PluriLayout.compactGap,
|
|
children: [
|
|
for (final segundos
|
|
in estado.timerSuenoPresetsSegundos)
|
|
ActionChip(
|
|
label: Text(
|
|
_formatearDuracionTimer(
|
|
AppLocalizations.of(ctx),
|
|
Duration(seconds: segundos),
|
|
),
|
|
),
|
|
onPressed: () {
|
|
estado.iniciarTimerDuracion(
|
|
Duration(seconds: segundos),
|
|
);
|
|
Navigator.pop(ctx);
|
|
},
|
|
),
|
|
ActionChip(
|
|
avatar: const Icon(
|
|
Icons.tune_rounded,
|
|
size: 18,
|
|
),
|
|
label: Text(
|
|
AppLocalizations.of(ctx).optionOther,
|
|
),
|
|
onPressed: () async {
|
|
final duracion =
|
|
await _pedirDuracionPersonalizada(ctx);
|
|
if (duracion == null || !ctx.mounted) return;
|
|
estado.iniciarTimerDuracion(duracion);
|
|
Navigator.pop(ctx);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<Duration?> _pedirDuracionPersonalizada(BuildContext context) {
|
|
return showModalBottomSheet<Duration>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
builder: (ctx) => const _TimerPersonalizadoSheet(),
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
class _TimerPersonalizadoSheet extends StatefulWidget {
|
|
const _TimerPersonalizadoSheet();
|
|
|
|
@override
|
|
State<_TimerPersonalizadoSheet> createState() =>
|
|
_TimerPersonalizadoSheetState();
|
|
}
|
|
|
|
class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> {
|
|
final _horasCtrl = TextEditingController();
|
|
final _minutosCtrl = TextEditingController(text: '15');
|
|
final _segundosCtrl = TextEditingController();
|
|
bool _guardarPreset = true;
|
|
|
|
@override
|
|
void dispose() {
|
|
_horasCtrl.dispose();
|
|
_minutosCtrl.dispose();
|
|
_segundosCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0;
|
|
|
|
Future<void> _confirmar() async {
|
|
final duracion = Duration(
|
|
hours: _leer(_horasCtrl),
|
|
minutes: _leer(_minutosCtrl),
|
|
seconds: _leer(_segundosCtrl),
|
|
);
|
|
if (duracion <= Duration.zero) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(AppLocalizations.of(context).durationGreaterThanZero),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (_guardarPreset) {
|
|
await context.read<EstadoRadio>().agregarTimerSuenoPreset(duracion);
|
|
}
|
|
if (mounted) Navigator.pop(context, duracion);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext 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(
|
|
AppLocalizations.of(context).customDurationTitle,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: PluriLayout.sectionGap),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _campoTiempo(
|
|
_horasCtrl,
|
|
AppLocalizations.of(context).hoursLabel,
|
|
),
|
|
),
|
|
const SizedBox(width: PluriLayout.compactGap),
|
|
Expanded(
|
|
child: _campoTiempo(
|
|
_minutosCtrl,
|
|
AppLocalizations.of(context).minutesLabel,
|
|
),
|
|
),
|
|
const SizedBox(width: PluriLayout.compactGap),
|
|
Expanded(
|
|
child: _campoTiempo(
|
|
_segundosCtrl,
|
|
AppLocalizations.of(context).secondsLabel,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: PluriLayout.compactGap),
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(AppLocalizations.of(context).saveQuickAccess),
|
|
value: _guardarPreset,
|
|
onChanged: (value) => setState(() => _guardarPreset = value),
|
|
),
|
|
const SizedBox(height: PluriLayout.sectionGap),
|
|
FilledButton.icon(
|
|
icon: const Icon(Icons.bedtime_rounded),
|
|
label: Text(AppLocalizations.of(context).startTimer),
|
|
onPressed: _confirmar,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _campoTiempo(TextEditingController controller, String label) {
|
|
return TextField(
|
|
controller: controller,
|
|
keyboardType: TextInputType.number,
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
);
|
|
}
|
|
}
|