1317 lines
44 KiB
Dart
1317 lines
44 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:share_plus/share_plus.dart' show Share, XFile;
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../estado/estado_idioma.dart';
|
|
import '../estado/estado_radio.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/emisora.dart';
|
|
import '../modelos/grupo_favoritos.dart';
|
|
import '../widgets/ecualizador_widget.dart';
|
|
import '../widgets/pluri_glass_surface.dart';
|
|
import '../widgets/pluri_icon.dart';
|
|
import '../widgets/pluri_layout.dart';
|
|
import '../widgets/pluri_premium_widgets.dart';
|
|
|
|
class PantallaAjustes extends StatelessWidget {
|
|
const PantallaAjustes({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return ListView(
|
|
padding: PluriLayout.pageListPadding,
|
|
children: [
|
|
PluriScreenHeader(
|
|
title: l10n.settingsTitle,
|
|
subtitle: l10n.settingsSubtitle,
|
|
glyph: PluriIconGlyph.settings,
|
|
trailing: PluriStatusPill(
|
|
icon: Icons.security_rounded,
|
|
label: l10n.settingsSafeStatus,
|
|
),
|
|
),
|
|
const Padding(
|
|
padding: PluriLayout.pageContentPadding,
|
|
child: _AjustesContent(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AjustesContent extends StatelessWidget {
|
|
const _AjustesContent();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: const [
|
|
_SeccionEcualizador(),
|
|
SizedBox(height: 12),
|
|
_SeccionGrabaciones(),
|
|
SizedBox(height: 12),
|
|
_SeccionTimerSueno(),
|
|
SizedBox(height: 12),
|
|
_SeccionIdioma(),
|
|
SizedBox(height: 12),
|
|
_SeccionOrdenListas(),
|
|
SizedBox(height: 12),
|
|
_SeccionGruposFavoritos(),
|
|
SizedBox(height: 12),
|
|
_SeccionEmisoraPreferida(),
|
|
SizedBox(height: 12),
|
|
_SeccionEmisoras(),
|
|
SizedBox(height: 12),
|
|
_SeccionBackup(),
|
|
SizedBox(height: 12),
|
|
_SeccionInfo(),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionGrabaciones extends StatelessWidget {
|
|
const _SeccionGrabaciones();
|
|
|
|
Future<void> _seleccionarRuta(BuildContext context) async {
|
|
final estado = context.read<EstadoRadio>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final l10n = AppLocalizations.of(context);
|
|
final ruta = await FilePicker.platform.getDirectoryPath(
|
|
dialogTitle: l10n.recordingsFolderDialogTitle,
|
|
);
|
|
if (ruta == null) return;
|
|
try {
|
|
await estado.cambiarDirectorioGrabacion(ruta);
|
|
if (!context.mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsPathUpdated)),
|
|
);
|
|
} catch (e) {
|
|
if (!context.mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsPathSaveError(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _restaurarRuta(BuildContext context) async {
|
|
final estado = context.read<EstadoRadio>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final l10n = AppLocalizations.of(context);
|
|
await estado.restaurarDirectorioGrabacion();
|
|
if (!context.mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)),
|
|
);
|
|
}
|
|
|
|
Future<void> _abrirCarpeta(BuildContext context) async {
|
|
final estado = context.read<EstadoRadio>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final l10n = AppLocalizations.of(context);
|
|
try {
|
|
final abierto = await estado.abrirDirectorioGrabacion();
|
|
if (!context.mounted) return;
|
|
if (!abierto) {
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsOpenFolderError(l10n.dash))),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (!context.mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsOpenFolderError(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _editarTamanoMaximo(BuildContext context) async {
|
|
final estado = context.read<EstadoRadio>();
|
|
final l10n = AppLocalizations.of(context);
|
|
final actualMb = _bytesAMegabytes(estado.maxBytesGrabacion);
|
|
final controller = TextEditingController(text: actualMb.toString());
|
|
|
|
final nuevoMb = await showModalBottomSheet<int>(
|
|
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(
|
|
l10n.recordingsMaxSizeDialogTitle,
|
|
style: Theme.of(ctx).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
keyboardType: TextInputType.number,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.recordingsMaxSizeMbLabel,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
FilledButton.icon(
|
|
onPressed: () {
|
|
final value = int.tryParse(controller.text.trim());
|
|
if (value == null || value <= 0) return;
|
|
Navigator.of(ctx).pop(value);
|
|
},
|
|
icon: const Icon(Icons.save_rounded),
|
|
label: Text(l10n.saveQuickAccessButton),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
controller.dispose();
|
|
if (nuevoMb == null || !context.mounted) return;
|
|
await estado.cambiarMaxBytesGrabacion(nuevoMb * 1024 * 1024);
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))),
|
|
);
|
|
}
|
|
|
|
int _bytesAMegabytes(int bytes) =>
|
|
(bytes / (1024 * 1024)).round().clamp(1, 1048576);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final estado = context.watch<EstadoRadio>();
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.radio_button_checked),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
l10n.recordingsSectionTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
],
|
|
),
|
|
FutureBuilder<String>(
|
|
future: estado.directorioGrabacionEfectivo(),
|
|
builder:
|
|
(ctx, snap) => ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.folder_outlined),
|
|
title: Text(l10n.recordingsFolderTitle),
|
|
subtitle: Text(
|
|
snap.data ?? l10n.recordingsPathCalculating,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
onTap: () => _seleccionarRuta(context),
|
|
),
|
|
),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
icon: const Icon(Icons.folder_open_rounded),
|
|
label: Text(l10n.recordingsChangePath),
|
|
onPressed: () => _seleccionarRuta(context),
|
|
),
|
|
FilledButton.tonalIcon(
|
|
icon: const Icon(Icons.folder_copy_rounded),
|
|
label: Text(l10n.recordingsOpenFolder),
|
|
onPressed: () => _abrirCarpeta(context),
|
|
),
|
|
IconButton.filledTonal(
|
|
tooltip: l10n.recordingsUseDefaultPath,
|
|
icon: const Icon(Icons.restore_rounded),
|
|
onPressed: () => _restaurarRuta(context),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.sd_storage_rounded),
|
|
title: Text(l10n.recordingsMaxSizeTitle),
|
|
subtitle: Text(
|
|
l10n.recordingsMaxSizeSubtitle(
|
|
_bytesAMegabytes(estado.maxBytesGrabacion),
|
|
),
|
|
),
|
|
onTap: () => _editarTamanoMaximo(context),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.recordingsOriginalStreamHint,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionTimerSueno extends StatelessWidget {
|
|
const _SeccionTimerSueno();
|
|
|
|
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(duracion)}',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
final estado = context.watch<EstadoRadio>();
|
|
final presets = estado.timerSuenoPresetsSegundos;
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.bedtime_rounded),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
l10n.timerSectionTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const Spacer(),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.add_rounded),
|
|
label: Text(l10n.timerSectionAdd),
|
|
onPressed: () => _anadirPreset(context),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
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(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 _SeccionIdioma extends StatelessWidget {
|
|
const _SeccionIdioma();
|
|
|
|
static const _codigoSistema = 'system';
|
|
static const _idiomas = [
|
|
_IdiomaDisponible(Locale('en'), 'English'),
|
|
_IdiomaDisponible(Locale('es'), 'Español'),
|
|
_IdiomaDisponible(Locale('zh'), '中文'),
|
|
_IdiomaDisponible(Locale('hi'), 'हिन्दी'),
|
|
_IdiomaDisponible(Locale('ar'), 'العربية'),
|
|
_IdiomaDisponible(Locale('pt'), 'Português'),
|
|
_IdiomaDisponible(Locale('fr'), 'Français'),
|
|
_IdiomaDisponible(Locale('ru'), 'Русский'),
|
|
_IdiomaDisponible(Locale('de'), 'Deutsch'),
|
|
_IdiomaDisponible(Locale('ja'), '日本語'),
|
|
_IdiomaDisponible(Locale('id'), 'Bahasa Indonesia'),
|
|
_IdiomaDisponible(Locale('bn'), 'বাংলা'),
|
|
_IdiomaDisponible(Locale('it'), 'Italiano'),
|
|
];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final estadoIdioma = context.watch<EstadoIdioma>();
|
|
final locale = estadoIdioma.localeSeleccionado;
|
|
final valorActual =
|
|
locale == null ? _codigoSistema : _codigoLocale(locale);
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.language_rounded),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
l10n.languageSectionTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.languageSectionDescription,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: valorActual,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.languageSectionTitle,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
items: [
|
|
DropdownMenuItem(
|
|
value: _codigoSistema,
|
|
child: Text(l10n.languageSystemDefault),
|
|
),
|
|
for (final idioma in _idiomas)
|
|
DropdownMenuItem(
|
|
value: _codigoLocale(idioma.locale),
|
|
child: Text(idioma.nombreNativo),
|
|
),
|
|
],
|
|
onChanged: (codigo) async {
|
|
if (codigo == null) return;
|
|
if (codigo == _codigoSistema) {
|
|
await context.read<EstadoIdioma>().seleccionarSistema();
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.languageUpdatedSystem)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final idioma = _idiomas.firstWhere(
|
|
(item) => _codigoLocale(item.locale) == codigo,
|
|
orElse: () => _idiomas.first,
|
|
);
|
|
await context.read<EstadoIdioma>().seleccionarLocale(
|
|
idioma.locale,
|
|
);
|
|
if (!context.mounted) return;
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(l10n.languageUpdated(idioma.nombreNativo)),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
static String _codigoLocale(Locale locale) {
|
|
final countryCode = locale.countryCode;
|
|
if (countryCode == null || countryCode.isEmpty) {
|
|
return locale.languageCode;
|
|
}
|
|
return '${locale.languageCode}_$countryCode';
|
|
}
|
|
}
|
|
|
|
class _IdiomaDisponible {
|
|
const _IdiomaDisponible(this.locale, this.nombreNativo);
|
|
|
|
final Locale locale;
|
|
final String nombreNativo;
|
|
}
|
|
|
|
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(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionEcualizador extends StatelessWidget {
|
|
const _SeccionEcualizador();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<EstadoRadio>(
|
|
builder: (ctx, estado, _) {
|
|
final disponible = estado.ecualizadorDisponible;
|
|
final l10n = AppLocalizations.of(ctx);
|
|
final emisoraActual = estado.emisoraActual;
|
|
final mostrarModoPorEmisora =
|
|
emisoraActual != null && estado.emisoraActualEsFavorita;
|
|
final usandoEqPropio = estado.emisoraActualTienePresetPropio;
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.equalizer),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
l10n.equalizerTitle,
|
|
style: Theme.of(ctx).textTheme.titleMedium,
|
|
),
|
|
const Spacer(),
|
|
Chip(
|
|
label: Text(
|
|
estado.ecualizadorActivo ? l10n.equalizerActive : l10n.equalizerDisabled,
|
|
),
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(l10n.equalizerEnable),
|
|
subtitle: Text(
|
|
disponible
|
|
? l10n.equalizerRealtimeSubtitle
|
|
: l10n.equalizerPendingSubtitle,
|
|
),
|
|
value: estado.ecualizadorActivo,
|
|
onChanged: estado.cambiarEcualizadorActivo,
|
|
),
|
|
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) =>
|
|
estado.cambiarModoEcualizadorEmisoraActual(
|
|
usarPropio: usarPropio,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 8),
|
|
PresetsEcualizadorWidget(
|
|
presetActual: estado.presetEcualizador,
|
|
onSeleccionar: (p) => estado.cambiarPresetEcualizador(p),
|
|
),
|
|
const SizedBox(height: 12),
|
|
EcualizadorWidget(
|
|
preset: estado.presetEcualizador,
|
|
onCambio: (p) => estado.cambiarPresetEcualizador(p),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionOrdenListas extends StatelessWidget {
|
|
const _SeccionOrdenListas();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final estado = context.watch<EstadoRadio>();
|
|
final l10n = AppLocalizations.of(context);
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.sort_rounded),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
l10n.stationOrderTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
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: {estado.ordenListas},
|
|
onSelectionChanged: (value) {
|
|
estado.cambiarOrdenListas(value.first);
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.stationOrderScopeDescription,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionGruposFavoritos extends StatelessWidget {
|
|
const _SeccionGruposFavoritos();
|
|
|
|
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 estado = context.watch<EstadoRadio>();
|
|
final l10n = AppLocalizations.of(context);
|
|
final grupos = estado.gruposFavoritos;
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.playlist_add_check_circle_rounded),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
l10n.favoriteGroupsTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.add_rounded),
|
|
label: Text(l10n.favoriteGroupsAdd),
|
|
onPressed: () => _editarGrupo(context),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(l10n.favoriteGroupsDescription),
|
|
const SizedBox(height: 8),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionEmisoraPreferida extends StatelessWidget {
|
|
const _SeccionEmisoraPreferida();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final estado = context.watch<EstadoRadio>();
|
|
final l10n = AppLocalizations.of(context);
|
|
final favoritas = estado.listaFavoritos;
|
|
final preferida = estado.emisoraPreferida;
|
|
final opciones = _opciones(estado, preferida);
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.radio_rounded),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
l10n.preferredStationTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
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(
|
|
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(preferida.nombre)
|
|
: l10n.preferredStationAutoUsing(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(EstadoRadio estado, Emisora? preferida) {
|
|
final base =
|
|
estado.listaFavoritos.isNotEmpty
|
|
? estado.listaFavoritos
|
|
: estado.emisorasDisponiblesPreferencia;
|
|
final mapa = <String, Emisora>{for (final emisora in base) emisora.uuid: emisora};
|
|
if (preferida != null) {
|
|
mapa[preferida.uuid] = preferida;
|
|
}
|
|
return mapa.values.toList();
|
|
}
|
|
}
|
|
|
|
class _SeccionEmisoras extends StatelessWidget {
|
|
const _SeccionEmisoras();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final estado = context.watch<EstadoRadio>();
|
|
final custom = estado.emisorasCustom;
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.add_circle_outline),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
AppLocalizations.of(context).customStationsTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const Spacer(),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Añadir'),
|
|
onPressed: () => _mostrarFormularioAnadir(context),
|
|
),
|
|
],
|
|
),
|
|
if (custom.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(
|
|
AppLocalizations.of(context).customStationsEmpty,
|
|
style: const TextStyle(color: Colors.grey),
|
|
),
|
|
)
|
|
else
|
|
for (final emisora in custom)
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.radio),
|
|
title: Text(emisora.nombre),
|
|
subtitle: Text(
|
|
emisora.url,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.play_arrow),
|
|
tooltip: AppLocalizations.of(context).playAction,
|
|
onPressed:
|
|
() => context.read<EstadoRadio>().reproducir(emisora),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline),
|
|
tooltip: AppLocalizations.of(context).deleteAction,
|
|
onPressed:
|
|
() => context
|
|
.read<EstadoRadio>()
|
|
.eliminarEmitoraCustom(emisora.uuid),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _mostrarFormularioAnadir(BuildContext context) async {
|
|
await showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: 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 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(
|
|
'Añadir emisora',
|
|
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: 'http://stream.ejemplo.com:8000/radio',
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
keyboardType: TextInputType.url,
|
|
validator: (v) {
|
|
if (v == null || v.trim().isEmpty) return AppLocalizations.of(context).requiredField;
|
|
final uri = Uri.tryParse(v.trim());
|
|
if (uri == null || !uri.hasScheme) return 'URL no válida';
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextFormField(
|
|
controller: _paisCtrl,
|
|
decoration: const InputDecoration(
|
|
labelText: 'País (opcional)',
|
|
border: 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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionBackup extends StatelessWidget {
|
|
const _SeccionBackup();
|
|
|
|
Future<void> _exportar(BuildContext context) async {
|
|
try {
|
|
final estado = context.read<EstadoRadio>();
|
|
final config = await estado.exportarConfig();
|
|
final json = const JsonEncoder.withIndent(' ').convert(config);
|
|
|
|
final dir = await getTemporaryDirectory();
|
|
final file = File('${dir.path}/pluriwave-backup.json');
|
|
await file.writeAsString(json);
|
|
|
|
await Share.shareXFiles(
|
|
[XFile(file.path)],
|
|
subject: 'PluriWave — copia de seguridad',
|
|
text:
|
|
'Configuración de PluriWave exportada el ${DateTime.now().toLocal()}',
|
|
);
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).backupExportError(e.toString()))));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _importar(BuildContext context) async {
|
|
try {
|
|
final result = await FilePicker.platform.pickFiles(
|
|
type: FileType.custom,
|
|
allowedExtensions: ['json'],
|
|
);
|
|
if (result == null || result.files.single.path == null) return;
|
|
|
|
final file = File(result.files.single.path!);
|
|
final json =
|
|
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
|
|
|
if (context.mounted) {
|
|
final confirmar = await showDialog<bool>(
|
|
context: context,
|
|
builder:
|
|
(ctx) => AlertDialog(
|
|
title: const Text('Importar configuración'),
|
|
content: const Text(
|
|
'Esto añadirá los favoritos, emisoras y presets del fichero. ¿Continuar?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(AppLocalizations.of(ctx).cancelAction),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(AppLocalizations.of(ctx).backupImportTitle),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmar != true) return;
|
|
if (context.mounted) {
|
|
final estado = context.read<EstadoRadio>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
await estado.importarConfig(json);
|
|
messenger.showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Configuración importada correctamente'),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).backupImportError(e.toString()))));
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.backup_outlined),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
AppLocalizations.of(context).backupSectionTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
],
|
|
),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.upload_outlined),
|
|
title: const Text('Exportar configuración'),
|
|
subtitle: Text(AppLocalizations.of(context).backupExportSubtitle),
|
|
onTap: () => _exportar(context),
|
|
),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.download_outlined),
|
|
title: const Text('Importar configuración'),
|
|
subtitle: Text(AppLocalizations.of(context).backupImportSubtitle),
|
|
onTap: () => _importar(context),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionInfo extends StatelessWidget {
|
|
const _SeccionInfo();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<EstadoRadio>(
|
|
builder:
|
|
(ctx, estado, _) => PluriGlassSurface(
|
|
child: Column(
|
|
children: [
|
|
FutureBuilder<PackageInfo>(
|
|
future: PackageInfo.fromPlatform(),
|
|
builder: (ctx, snap) {
|
|
final version =
|
|
snap.hasData
|
|
? 'v${snap.data!.version}+${snap.data!.buildNumber}'
|
|
: 'Cargando versión...';
|
|
return ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const PluriIcon(
|
|
glyph: PluriIconGlyph.settings,
|
|
variant: PluriIconVariant.filled,
|
|
),
|
|
title: const Text('PluriWave'),
|
|
subtitle: Text(AppLocalizations.of(ctx).appVersionSubtitle(version)),
|
|
);
|
|
},
|
|
),
|
|
FutureBuilder<int>(
|
|
future: estado.favoritos.obtenerTodos().then((l) => l.length),
|
|
builder:
|
|
(ctx, snap) => ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.favorite_outline),
|
|
title: Text(AppLocalizations.of(ctx).savedFavoritesTitle),
|
|
trailing: Text(
|
|
snap.data?.toString() ?? '—',
|
|
style: Theme.of(ctx).textTheme.bodyLarge,
|
|
),
|
|
),
|
|
),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.verified_outlined),
|
|
title: Text(AppLocalizations.of(ctx).stationFilterTitle),
|
|
subtitle: Text(AppLocalizations.of(ctx).stationFilterSubtitle),
|
|
trailing: const Icon(Icons.check_circle, color: Colors.green),
|
|
),
|
|
const ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: Icon(Icons.music_note_outlined),
|
|
title: Text('Audio en background'),
|
|
subtitle: Text('Continúa al apagar la pantalla'),
|
|
trailing: Icon(Icons.check_circle, color: Colors.green),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _formatearDuracionTimer(Duration duracion) {
|
|
final horas = duracion.inHours;
|
|
final minutos = duracion.inMinutes.remainder(60);
|
|
final segundos = duracion.inSeconds.remainder(60);
|
|
if (horas > 0) {
|
|
return '${horas}h ${minutos.toString().padLeft(2, '0')}m ${segundos.toString().padLeft(2, '0')}s';
|
|
}
|
|
if (minutos > 0) {
|
|
return segundos == 0 ? '$minutos min' : '${minutos}m ${segundos}s';
|
|
}
|
|
return '$segundos s';
|
|
}
|