feat(mvp): PluriWave Fase 1 — estructura completa de la app
Some checks failed
Flutter CI/CD — PluriWave / Test + Build (pull_request) Has been cancelled

- Modelo Emisora: campos completos Radio Browser API (fromApi + fromMap)
- ServicioRadio: cliente Radio Browser API (populares, tendencias, buscar por nombre/país/idioma/tag)
- ServicioAudio: just_audio + audio_service wrapper (play/pause/stop/toggle, fade, background handler)
- ServicioTimer: countdown con fade out gradual (15/30/60/90 min)
- ServicioFavoritos: actualizado a v2 con campos codec/bitrate/votes/clickcount
- EstadoRadio: ChangeNotifier global con Provider
- PantallaInicio: grid emisoras populares, chips género, shimmer loading, pull-to-refresh
- PantallaBuscar: SearchBar + filtros país/idioma, lista resultados
- PantallaFavoritos: ReorderableListView + swipe-to-delete (Dismissible)
- TarjetaEmisora: card + modo compacto ListTile, cached_network_image, shimmer fallback
- MiniReproductor: barra inferior persistente con stream de estado
- app.dart: MaterialApp + Provider + NavigationBar + timer dialog
- main.dart: punto de entrada limpio
- AndroidManifest.xml: permisos INTERNET + FOREGROUND_SERVICE + audio_service receivers
This commit is contained in:
Kira (Agent)
2026-04-04 17:15:18 +02:00
parent 25a3f3cf5a
commit e9d1f67aa4
14 changed files with 1599 additions and 340 deletions

View File

@@ -1,8 +1,8 @@
/// Modelo de datos de una emisora de radio.
///
/// Representa una emisora favorita almacenada en SQLite.
/// Los campos opcionales (favicon, pais, idioma, tags) pueden ser null
/// cuando la emisora no dispone de esa información.
/// Unifica los campos de la Radio Browser API con los de la tabla SQLite
/// de favoritos. Los campos opcionales pueden ser null cuando la emisora
/// no dispone de esa información.
class Emisora {
final int? id;
final String uuid;
@@ -10,8 +10,13 @@ class Emisora {
final String url;
final String? favicon;
final String? pais;
final String? codigoPais; // ISO 3166-1 alpha-2
final String? idioma;
final String? tags;
final String? codec;
final int? bitrate;
final int votes;
final int clickcount;
final int orden;
const Emisora({
@@ -21,11 +26,34 @@ class Emisora {
required this.url,
this.favicon,
this.pais,
this.codigoPais,
this.idioma,
this.tags,
this.codec,
this.bitrate,
this.votes = 0,
this.clickcount = 0,
this.orden = 0,
});
/// Construye una [Emisora] desde la respuesta JSON de Radio Browser API.
factory Emisora.fromApi(Map<String, dynamic> json) {
return Emisora(
uuid: json['stationuuid'] as String? ?? '',
nombre: json['name'] as String? ?? 'Sin nombre',
url: json['url_resolved'] as String? ?? json['url'] as String? ?? '',
favicon: _nonEmpty(json['favicon'] as String?),
pais: _nonEmpty(json['country'] as String?),
codigoPais: _nonEmpty(json['countrycode'] as String?),
idioma: _nonEmpty(json['language'] as String?),
tags: _nonEmpty(json['tags'] as String?),
codec: _nonEmpty(json['codec'] as String?),
bitrate: json['bitrate'] as int?,
votes: json['votes'] as int? ?? 0,
clickcount: json['clickcount'] as int? ?? 0,
);
}
/// Construye una [Emisora] desde una fila de la tabla `favoritos`.
factory Emisora.fromMap(Map<String, dynamic> map) {
return Emisora(
@@ -35,14 +63,18 @@ class Emisora {
url: map['url'] as String,
favicon: map['favicon'] as String?,
pais: map['pais'] as String?,
codigoPais: map['codigo_pais'] as String?,
idioma: map['idioma'] as String?,
tags: map['tags'] as String?,
codec: map['codec'] as String?,
bitrate: map['bitrate'] as int?,
votes: map['votes'] as int? ?? 0,
clickcount: map['clickcount'] as int? ?? 0,
orden: map['orden'] as int? ?? 0,
);
}
/// Serializa la emisora para inserción/actualización en SQLite.
/// No incluye [id] — lo gestiona la BD.
/// Serializa para inserción/actualización en SQLite.
Map<String, dynamic> toMap() {
return {
'uuid': uuid,
@@ -50,13 +82,17 @@ class Emisora {
'url': url,
'favicon': favicon,
'pais': pais,
'codigo_pais': codigoPais,
'idioma': idioma,
'tags': tags,
'codec': codec,
'bitrate': bitrate,
'votes': votes,
'clickcount': clickcount,
'orden': orden,
};
}
/// Devuelve una copia con los campos indicados modificados.
Emisora copyWith({
int? id,
String? uuid,
@@ -64,8 +100,13 @@ class Emisora {
String? url,
String? favicon,
String? pais,
String? codigoPais,
String? idioma,
String? tags,
String? codec,
int? bitrate,
int? votes,
int? clickcount,
int? orden,
}) {
return Emisora(
@@ -75,22 +116,33 @@ class Emisora {
url: url ?? this.url,
favicon: favicon ?? this.favicon,
pais: pais ?? this.pais,
codigoPais: codigoPais ?? this.codigoPais,
idioma: idioma ?? this.idioma,
tags: tags ?? this.tags,
codec: codec ?? this.codec,
bitrate: bitrate ?? this.bitrate,
votes: votes ?? this.votes,
clickcount: clickcount ?? this.clickcount,
orden: orden ?? this.orden,
);
}
/// Lista de géneros/tags como lista limpia.
List<String> get generos {
if (tags == null || tags!.isEmpty) return [];
return tags!.split(',').map((t) => t.trim()).where((t) => t.isNotEmpty).toList();
}
static String? _nonEmpty(String? s) =>
(s == null || s.trim().isEmpty) ? null : s.trim();
@override
String toString() =>
'Emisora(id: $id, uuid: $uuid, nombre: $nombre, orden: $orden)';
String toString() => 'Emisora(uuid: $uuid, nombre: $nombre)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Emisora &&
runtimeType == other.runtimeType &&
uuid == other.uuid;
other is Emisora && runtimeType == other.runtimeType && uuid == other.uuid;
@override
int get hashCode => uuid.hashCode;