posponerAlarma() and posponerProximaDesdePreaviso() called the native scheduler with no error handling, unlike guardarAlarma(). When the native call failed (e.g. revoked exact-alarm permission), the exception escaped before notifyListeners() ran, leaving the alarm list stuck on stale data with no real alarm scheduled and no snooze countdown notification. Both methods now mirror guardarAlarma()'s pattern: permission pre-check, try/catch into _error, and an unconditional notifyListeners() so the UI always reflects the outcome. Failures surface via SnackBar in the ringing screen and in app.dart's postpone-next handler.
625 lines
21 KiB
Dart
625 lines
21 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',
|
|
);
|
|
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 {
|
|
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(),
|
|
),
|
|
);
|
|
}
|
|
}
|