feat(escuchar): replace discovery browser with embedded player and favorites grid
Restructures PantallaInicio's top of screen: a new _EscucharHero (square art, live/offline pill, VisualizadorAudio at barras: 30 / altura: 26 / color: liveGreen, a 5-action transport row - favorite, EQ toggle, stop, play/pause, sleep - plus a tool-tray entry chip opening the full player) replaces the old PluriScreenHeader hero, and a new "Tus emisoras" section (favorites preview, capped, "Ver todas") follows it. Per design ADR-7, EstadoRadio stays the single source of truth: the hero is a StatelessWidget with no cached fields, reading emisoraActual via context.select (uuid-based equality scopes rebuilds to real station changes) and the fast-changing playback status via StreamBuilder, the same pattern _Controles/MiniReproductor already use. The still-present discovery sections (_seccionCercanas onward, including the old grid) are deliberately left in place - WU6 relocates them to Buscar and deletes them from here; removing them now would leave that content nowhere until WU6 lands. MiniReproductor gains a `visible` parameter (default true) and a measured `static const double altura`. app.dart passes `visible: indice != RaizPluriWave.escuchar.index`, hiding it visually only (SizedBox.shrink()) while Escuchar is active, since the hero already shows the same station - the State stays mounted so its didChangeDependencies side effect (configurarLocalizaciones, S3-R3) keeps running regardless of tab. altura was measured empirically (72.0, via tester.getSize) rather than guessed, backing a new derived PluriLayout.escucharBottomChromeInset constant now wired into PantallaInicio's own bottom padding. "Ver todas" switches roots via EstadoNavegacionRaiz.irA(favoritos), verified via a NavigatorObserver asserting the push count is unchanged (switches tabs, does not push). Fixed a pre-existing test-infrastructure gap while writing the anti-cache test: no test in this codebase had ever exercised ServicioAudio.androidAudioSessionIdStream against a bare FakeServicioAudio (pantalla_reproductor.dart has always read it but has no test file at all) - the real getter needs registrarHandler() (main.dart, production only) and threw otherwise. Added an empty stream override to FakeServicioAudio, matching VisualizadorAudio's own documented no-native-session fallback. Tests: 614 -> 618 (2 skipped, unchanged). flutter analyze unchanged at 1 pre-existing info. git diff empty for visualizador_audio.dart and estado_radio.dart - this WU touches neither.
This commit is contained in:
@@ -1,18 +1,26 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shimmer/shimmer.dart' as shimmer;
|
||||
|
||||
import '../estado/estado_busqueda.dart';
|
||||
import '../estado/estado_ecualizador.dart';
|
||||
import '../estado/estado_navegacion.dart';
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
import '../tema/pluri_animate.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import '../widgets/visualizador_audio.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
|
||||
import 'pantalla_reproductor.dart';
|
||||
import 'reproducir_minimizado.dart';
|
||||
|
||||
/// Pantalla principal: emisoras populares y por género.
|
||||
@@ -53,7 +61,13 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
onRefresh: () => context.read<EstadoRadio>().cargarPopulares(),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _heroHeader(context, l10n)),
|
||||
// WU5: replaces the old PluriScreenHeader hero. The discovery
|
||||
// sections below (_seccionCercanas onward) are deliberately left
|
||||
// in place — WU6 relocates them to Buscar and deletes them from
|
||||
// here; removing them now would leave that content nowhere until
|
||||
// WU6 lands.
|
||||
const SliverToBoxAdapter(child: _EscucharHero()),
|
||||
SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)),
|
||||
SliverToBoxAdapter(child: _seccionCercanas(context, theme, l10n)),
|
||||
SliverToBoxAdapter(child: _seccionTendencias(context, theme, l10n)),
|
||||
SliverToBoxAdapter(child: _chipGeneros(context, theme, l10n)),
|
||||
@@ -62,11 +76,15 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
child: _errorBanner(context, error, theme, l10n),
|
||||
),
|
||||
SliverPadding(
|
||||
// ADR-7(b): the mini player is hidden on this whole screen
|
||||
// (app.dart), so its content needs less bottom padding than
|
||||
// every other root — escucharBottomChromeInset, not the plain
|
||||
// bottomChromeInset every other root/scrollable uses.
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
0,
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.bottomChromeInset,
|
||||
PluriLayout.escucharBottomChromeInset,
|
||||
),
|
||||
sliver: _gridEmisoras(context, l10n),
|
||||
),
|
||||
@@ -75,27 +93,84 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _heroHeader(BuildContext context, AppLocalizations l10n) {
|
||||
final totalEmisoras = context.select<EstadoRadio, int>(
|
||||
(e) => e.emisorasInicio.length,
|
||||
/// WU5 task 5.8: a preview of `listaFavoritos` (capped — full browsing,
|
||||
/// filtering, and reordering live on Favoritos itself, WU4), with "Ver
|
||||
/// todas" switching the root tab via `EstadoNavegacionRaiz.irA` rather
|
||||
/// than pushing a route (`app-navigation-shell` — Root-to-Root Switching
|
||||
/// Without Push).
|
||||
static const _capTusEmisoras = 6;
|
||||
|
||||
Widget _seccionTusEmisoras(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
final favoritos = context.select<EstadoRadio, List<Emisora>>(
|
||||
(e) => e.listaFavoritos,
|
||||
);
|
||||
return PluriScreenHeader(
|
||||
title: l10n.appTitle,
|
||||
subtitle: l10n.homeScreenSubtitle,
|
||||
glyph: PluriIconGlyph.home,
|
||||
primaryActionLabel: l10n.exploreStations,
|
||||
onPrimaryAction: () => context.read<EstadoRadio>().cargarPopulares(),
|
||||
trailing: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
PluriStatusPill(
|
||||
icon: Icons.public_rounded,
|
||||
label: l10n.stationsCount(totalEmisoras),
|
||||
accent: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
PluriStatusPill(icon: Icons.hd_rounded, label: l10n.qualityHd),
|
||||
],
|
||||
final mostrados = favoritos.take(_capTusEmisoras).toList();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
8,
|
||||
PluriLayout.horizontal,
|
||||
0,
|
||||
),
|
||||
child: PluriGlassSurface(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.yourStationsTitle,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed:
|
||||
() => context.read<EstadoNavegacionRaiz>().irA(
|
||||
RaizPluriWave.favoritos,
|
||||
),
|
||||
child: Text(l10n.seeAllAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (mostrados.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
l10n.favoritesEmptySubtitle,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 76,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: mostrados.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final emisora = mostrados[i];
|
||||
return SizedBox(
|
||||
width: 260,
|
||||
child: TarjetaEmisora(
|
||||
emisora: emisora,
|
||||
esCompacta: true,
|
||||
onTap: () => reproducirMinimizado(context, emisora),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -410,3 +485,378 @@ class _ChipShimmer extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the
|
||||
/// single source of truth (`emisoraActual` already feeds `MiniReproductor`
|
||||
/// and `PantallaReproductor`) — this hero is a third VIEW, never a third
|
||||
/// STATE.
|
||||
///
|
||||
/// Binding rules this class exists to enforce:
|
||||
/// 1. StatelessWidget — no cached `Emisora`, no local playback flag.
|
||||
/// 2. Reads use `context.select` per scalar (here, `emisoraActual` itself —
|
||||
/// `Emisora`'s own `==`/`hashCode` are uuid-based, so this only rebuilds
|
||||
/// the hero when the STATION actually changes, not on every audio buffer
|
||||
/// event `EstadoRadio` also notifies on). Fast-changing playback status
|
||||
/// (`EstadoReproduccion`) is read via `StreamBuilder` instead, the same
|
||||
/// pattern `_Controles`/`MiniReproductor` already use, so status ticks
|
||||
/// don't even reach this widget's own rebuild path.
|
||||
/// 3. Transport calls the SAME `EstadoRadio`/`EstadoEcualizador` methods the
|
||||
/// full player calls — no new playback methods.
|
||||
/// 4. `VisualizadorAudio` is reused UNCHANGED, just re-parameterised
|
||||
/// (`barras: 30`, `altura: 26`, `color: liveGreen`).
|
||||
class _EscucharHero extends StatelessWidget {
|
||||
const _EscucharHero();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final emisora = context.select<EstadoRadio, Emisora?>(
|
||||
(e) => e.emisoraActual,
|
||||
);
|
||||
|
||||
if (emisora == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
8,
|
||||
PluriLayout.horizontal,
|
||||
0,
|
||||
),
|
||||
child: PluriGlassSurface(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.nothingPlayingTitle,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.nothingPlayingSubtitle,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final stationName = localizedStationName(l10n, emisora.nombre);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
8,
|
||||
PluriLayout.horizontal,
|
||||
0,
|
||||
),
|
||||
child: PluriGlassSurface(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ArteEscuchar(emisora: emisora),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StreamBuilder<EstadoReproduccion>(
|
||||
stream: estado.estadoStream,
|
||||
builder: (context, snap) {
|
||||
final s = snap.data ?? EstadoReproduccion.detenido;
|
||||
final enVivo = s == EstadoReproduccion.reproduciendo;
|
||||
return PluriStatusPill(
|
||||
icon:
|
||||
enVivo
|
||||
? Icons.podcasts_rounded
|
||||
: Icons.pause_circle_outline_rounded,
|
||||
label: enVivo ? l10n.liveNow : l10n.notPlaying,
|
||||
accent:
|
||||
enVivo ? context.pluriTokens.liveGreen : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
stationName,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
VisualizadorAudio(
|
||||
estadoStream: estado.estadoStream,
|
||||
androidAudioSessionIdStream:
|
||||
estado.audio.androidAudioSessionIdStream,
|
||||
barras: 30,
|
||||
altura: 26,
|
||||
color: context.pluriTokens.liveGreen,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_FilaTransporteEscuchar(emisora: emisora),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.tune_rounded, size: 18),
|
||||
label: Text(l10n.openFullPlayerTooltip),
|
||||
onPressed: () => PantallaReproductor.abrir(context, emisora),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Square art (the design's requested shape for the Escuchar hero — the
|
||||
/// full player's own `_WaveHero`, `pantalla_reproductor.dart`, stays
|
||||
/// circular and unchanged).
|
||||
class _ArteEscuchar extends StatelessWidget {
|
||||
const _ArteEscuchar({required this.emisora});
|
||||
|
||||
final Emisora emisora;
|
||||
|
||||
static const _lado = 84.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final radius = BorderRadius.circular(context.pluriTokens.radiusMd);
|
||||
return PluriGlassSurface(
|
||||
padding: EdgeInsets.zero,
|
||||
borderRadius: radius,
|
||||
child: SizedBox(
|
||||
width: _lado,
|
||||
height: _lado,
|
||||
child: ClipRRect(
|
||||
borderRadius: radius,
|
||||
child:
|
||||
(emisora.favicon != null && emisora.favicon!.isNotEmpty)
|
||||
? CachedNetworkImage(
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmerCuadrado(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
)
|
||||
: _iconoFallback(theme),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _shimmerCuadrado(ThemeData theme) => shimmer.Shimmer.fromColors(
|
||||
baseColor: theme.colorScheme.surfaceContainerHighest,
|
||||
highlightColor: theme.colorScheme.surface,
|
||||
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
||||
);
|
||||
|
||||
Widget _iconoFallback(ThemeData theme) => Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 36,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The hero's transport row — favorite / EQ toggle / stop / play-pause
|
||||
/// (primary) / sleep, in that order, with sleep as the documented 5th
|
||||
/// action. Every action calls an EXISTING `EstadoRadio`/`EstadoEcualizador`
|
||||
/// method — no new playback surface (ADR-7 rule 3).
|
||||
class _FilaTransporteEscuchar extends StatelessWidget {
|
||||
const _FilaTransporteEscuchar({required this.emisora});
|
||||
|
||||
final Emisora emisora;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final t = context.pluriTokens;
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final esFavorito = context.select<EstadoRadio, bool>(
|
||||
(e) => e.listaFavoritos.any((x) => x.uuid == emisora.uuid),
|
||||
);
|
||||
final eqActivo = context.select<EstadoEcualizador, bool>((e) => e.activo);
|
||||
final timerActivo = context.select<EstadoRadio, bool>(
|
||||
(e) => e.timer.activo,
|
||||
);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip:
|
||||
esFavorito
|
||||
? l10n.favoritesRemoveTooltip
|
||||
: l10n.favoritesAddTooltip,
|
||||
icon: Icon(
|
||||
esFavorito
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_outline_rounded,
|
||||
color: esFavorito ? Theme.of(context).colorScheme.error : null,
|
||||
),
|
||||
onPressed: () => estado.toggleFavorito(emisora),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: eqActivo ? l10n.equalizerDisable : l10n.equalizerEnable,
|
||||
icon: Icon(
|
||||
eqActivo ? Icons.equalizer_rounded : Icons.equalizer_outlined,
|
||||
color: eqActivo ? t.warmCoral : null,
|
||||
),
|
||||
onPressed:
|
||||
() => context.read<EstadoEcualizador>().cambiarActivo(!eqActivo),
|
||||
),
|
||||
StreamBuilder<EstadoReproduccion>(
|
||||
stream: estado.estadoStream,
|
||||
builder: (context, snap) {
|
||||
final s = snap.data ?? EstadoReproduccion.detenido;
|
||||
final cargando =
|
||||
s == EstadoReproduccion.cargando ||
|
||||
s == EstadoReproduccion.reconectando;
|
||||
return IconButton(
|
||||
tooltip: l10n.stopAction,
|
||||
icon: const Icon(Icons.stop_circle_outlined),
|
||||
onPressed: cargando ? null : estado.detenerReproduccion,
|
||||
);
|
||||
},
|
||||
),
|
||||
StreamBuilder<EstadoReproduccion>(
|
||||
stream: estado.estadoStream,
|
||||
builder: (context, snap) {
|
||||
final s = snap.data ?? EstadoReproduccion.detenido;
|
||||
final reproduciendo = s == EstadoReproduccion.reproduciendo;
|
||||
final cargando =
|
||||
s == EstadoReproduccion.cargando ||
|
||||
s == EstadoReproduccion.reconectando;
|
||||
return SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
onPressed:
|
||||
cargando
|
||||
? null
|
||||
: () {
|
||||
if (reproduciendo ||
|
||||
s == EstadoReproduccion.pausado) {
|
||||
estado.togglePlay();
|
||||
} else {
|
||||
estado.reproducir(emisora);
|
||||
}
|
||||
},
|
||||
child:
|
||||
cargando
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
reproduciendo
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.sleepTimer,
|
||||
icon: Icon(
|
||||
Icons.bedtime_rounded,
|
||||
color: timerActivo ? t.warmCoral : null,
|
||||
),
|
||||
onPressed: () => _mostrarTimerSheet(context, estado, l10n),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _mostrarTimerSheet(
|
||||
BuildContext context,
|
||||
EstadoRadio estado,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder:
|
||||
(ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.sleepTimer,
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (estado.timer.activo)
|
||||
FilledButton.tonal(
|
||||
onPressed: () {
|
||||
estado.cancelarTimer();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: Text(l10n.cancelTimer),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final segundos in estado.timerSuenoPresetsSegundos)
|
||||
ActionChip(
|
||||
label: Text(_formatearMinutos(l10n, segundos)),
|
||||
onPressed: () {
|
||||
estado.iniciarTimerDuracion(
|
||||
Duration(seconds: segundos),
|
||||
);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatearMinutos(AppLocalizations l10n, int segundos) {
|
||||
final d = Duration(seconds: segundos);
|
||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
if (d.inHours > 0) {
|
||||
return l10n.durationHoursMinutesSeconds(d.inHours, m, s);
|
||||
}
|
||||
return d.inSeconds.remainder(60) == 0
|
||||
? l10n.durationMinutesOnly(d.inMinutes)
|
||||
: l10n.durationMinutesSeconds(d.inMinutes, s);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user