feat(quality): harden lint rules and add quality-gate tests

This commit is contained in:
2026-06-12 00:05:06 +02:00
parent 202bef3539
commit 8a032e6e62
21 changed files with 485 additions and 140 deletions
+2 -3
View File
@@ -49,9 +49,8 @@ class EstadoIdioma extends ChangeNotifier {
final partes = value.split('_');
final languageCode = partes.first;
if (languageCode.isEmpty) return null;
final countryCode = partes.length > 1 && partes[1].isNotEmpty
? partes[1]
: null;
final countryCode =
partes.length > 1 && partes[1].isNotEmpty ? partes[1] : null;
return Locale.fromSubtags(
languageCode: languageCode,
countryCode: countryCode,
+8 -2
View File
@@ -136,7 +136,11 @@ class Emisora {
/// 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();
return tags!
.split(',')
.map((t) => t.trim())
.where((t) => t.isNotEmpty)
.toList();
}
static String? _nonEmpty(String? s) =>
@@ -148,7 +152,9 @@ class Emisora {
@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;
+36 -10
View File
@@ -5,21 +5,47 @@ class PresetEcualizador {
final List<double> bandas; // 5 valores entre -12.0 y +12.0 dB
const PresetEcualizador({required this.nombre, required this.bandas})
: assert(bandas.length == 5);
: assert(bandas.length == 5);
static final flat = PresetEcualizador(nombre: 'Flat', bandas: [0.0, 0.0, 0.0, 0.0, 0.0]);
static final rock = PresetEcualizador(nombre: 'Rock', bandas: [2.0, 1.0, -1.0, 2.0, 3.0]);
static final pop = PresetEcualizador(nombre: 'Pop', bandas: [1.0, 1.5, 0.5, 1.0, 1.5]);
static final bassBoost = PresetEcualizador(nombre: 'Bass Boost', bandas: [5.0, 3.0, -1.0, 0.5, 0.0]);
static final jazz = PresetEcualizador(nombre: 'Jazz', bandas: [3.0, -1.0, -1.5, 2.0, 4.0]);
static final voz = PresetEcualizador(nombre: 'Voz', bandas: [-2.0, -1.0, 2.0, 3.0, 1.0]);
static final flat = PresetEcualizador(
nombre: 'Flat',
bandas: [0.0, 0.0, 0.0, 0.0, 0.0],
);
static final rock = PresetEcualizador(
nombre: 'Rock',
bandas: [2.0, 1.0, -1.0, 2.0, 3.0],
);
static final pop = PresetEcualizador(
nombre: 'Pop',
bandas: [1.0, 1.5, 0.5, 1.0, 1.5],
);
static final bassBoost = PresetEcualizador(
nombre: 'Bass Boost',
bandas: [5.0, 3.0, -1.0, 0.5, 0.0],
);
static final jazz = PresetEcualizador(
nombre: 'Jazz',
bandas: [3.0, -1.0, -1.5, 2.0, 4.0],
);
static final voz = PresetEcualizador(
nombre: 'Voz',
bandas: [-2.0, -1.0, 2.0, 3.0, 1.0],
);
static final presets = [flat, rock, pop, bassBoost, jazz, voz];
factory PresetEcualizador.desdeJson(Map<String, dynamic> json) {
final raw = (json['bandas'] as List?)?.map((e) => (e as num).toDouble()).toList() ?? <double>[];
final bandas = List<double>.generate(5, (i) => i < raw.length ? raw[i] : 0.0);
return PresetEcualizador(nombre: json['nombre'] as String? ?? 'Personalizado', bandas: bandas);
final raw =
(json['bandas'] as List?)?.map((e) => (e as num).toDouble()).toList() ??
<double>[];
final bandas = List<double>.generate(
5,
(i) => i < raw.length ? raw[i] : 0.0,
);
return PresetEcualizador(
nombre: json['nombre'] as String? ?? 'Personalizado',
bandas: bandas,
);
}
Map<String, dynamic> toJson() => {'nombre': nombre, 'bandas': bandas};
+2 -3
View File
@@ -1226,11 +1226,10 @@ class _SeccionBackup extends StatelessWidget {
if (result == null || result.files.single.path == null) return;
final file = File(result.files.single.path!);
final contenido = await file.readAsString();
if (!context.mounted) return;
// Parsing is owned by ServicioExportImport (S4-R4): null = malformed.
final json = context.read<EstadoRadio>().parsearConfigJson(
await file.readAsString(),
);
final json = context.read<EstadoRadio>().parsearConfigJson(contenido);
if (json == null) {
throw const FormatException('invalid backup file');
}
+12 -10
View File
@@ -18,9 +18,9 @@ class ServicioFavoritos {
DatabaseFactory? databaseFactory,
Future<String> Function()? databasePathProvider,
String? databaseName,
}) : _databaseFactory = databaseFactory,
_databasePathProvider = databasePathProvider ?? getDatabasesPath,
_databaseName = databaseName ?? _dbName;
}) : _databaseFactory = databaseFactory,
_databasePathProvider = databasePathProvider ?? getDatabasesPath,
_databaseName = databaseName ?? _dbName;
final DatabaseFactory? _databaseFactory;
final Future<String> Function() _databasePathProvider;
@@ -175,7 +175,8 @@ class ServicioFavoritos {
Future<GrupoFavoritos> crearGrupo(String nombre) async {
final db = await _database;
final normalizado = _normalizarNombreGrupo(nombre);
final maxOrden = Sqflite.firstIntValue(
final maxOrden =
Sqflite.firstIntValue(
await db.rawQuery('SELECT MAX(orden) FROM grupos_favoritos'),
) ??
0;
@@ -232,7 +233,8 @@ class ServicioFavoritos {
Future<void> asignarGrupo(String uuid, String grupoId) async {
final db = await _database;
final existe = Sqflite.firstIntValue(
final existe =
Sqflite.firstIntValue(
await db.rawQuery(
'SELECT COUNT(*) FROM grupos_favoritos WHERE id = ?',
[grupoId],
@@ -250,7 +252,8 @@ class ServicioFavoritos {
Future<void> agregar(Emisora emisora) async {
final db = await _database;
final maxOrden = Sqflite.firstIntValue(
final maxOrden =
Sqflite.firstIntValue(
await db.rawQuery('SELECT MAX(orden) FROM favoritos'),
) ??
-1;
@@ -273,10 +276,9 @@ class ServicioFavoritos {
Future<bool> esFavorito(String uuid) async {
final db = await _database;
final count = Sqflite.firstIntValue(
await db.rawQuery(
'SELECT COUNT(*) FROM favoritos WHERE uuid = ?',
[uuid],
),
await db.rawQuery('SELECT COUNT(*) FROM favoritos WHERE uuid = ?', [
uuid,
]),
);
return (count ?? 0) > 0;
}
+3 -2
View File
@@ -78,7 +78,6 @@ class ServicioTimer {
_controller.add(_tiempoRestante);
}
// if (restante <= Duration.zero) {
// _tiempoRestante = Duration.zero;
// _controller.add(_tiempoRestante);
@@ -104,7 +103,9 @@ class ServicioTimer {
if (paso >= pasos) {
_fadeTicker?.cancel();
await _audio.detener();
await _audio.setVolumen(volumenInicial); // restaurar volumen para próxima vez
await _audio.setVolumen(
volumenInicial,
); // restaurar volumen para próxima vez
}
});
}
+4 -1
View File
@@ -37,7 +37,10 @@ class PluriWaveMotion extends ThemeExtension<PluriWaveMotion> {
}
@override
ThemeExtension<PluriWaveMotion> lerp(covariant ThemeExtension<PluriWaveMotion>? other, double t) {
ThemeExtension<PluriWaveMotion> lerp(
covariant ThemeExtension<PluriWaveMotion>? other,
double t,
) {
if (other is! PluriWaveMotion) return this;
return t < 0.5 ? this : other;
}
+12 -8
View File
@@ -22,11 +22,10 @@ abstract final class PluriWaveTheme {
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: tokens.deepViolet,
textTheme: GoogleFonts.plusJakartaSansTextTheme(ThemeData.dark().textTheme),
extensions: const <ThemeExtension<dynamic>>[
tokens,
PluriWaveMotion.dark,
],
textTheme: GoogleFonts.plusJakartaSansTextTheme(
ThemeData.dark().textTheme,
),
extensions: const <ThemeExtension<dynamic>>[tokens, PluriWaveMotion.dark],
appBarTheme: const AppBarTheme(
centerTitle: false,
backgroundColor: Colors.transparent,
@@ -39,7 +38,10 @@ abstract final class PluriWaveTheme {
indicatorColor: tokens.electricMagenta.withValues(alpha: 0.18),
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle(
fontWeight: states.contains(WidgetState.selected) ? FontWeight.w800 : FontWeight.w600,
fontWeight:
states.contains(WidgetState.selected)
? FontWeight.w800
: FontWeight.w600,
fontSize: 12,
),
),
@@ -62,7 +64,9 @@ abstract final class PluriWaveTheme {
}
extension PluriWaveThemeContextX on BuildContext {
PluriWaveTokens get pluriTokens => Theme.of(this).extension<PluriWaveTokens>() ?? PluriWaveTokens.dark;
PluriWaveTokens get pluriTokens =>
Theme.of(this).extension<PluriWaveTokens>() ?? PluriWaveTokens.dark;
PluriWaveMotion get pluriMotion => Theme.of(this).extension<PluriWaveMotion>() ?? PluriWaveMotion.dark;
PluriWaveMotion get pluriMotion =>
Theme.of(this).extension<PluriWaveMotion>() ?? PluriWaveMotion.dark;
}
+54 -23
View File
@@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/preset_ecualizador.dart';
@@ -9,7 +9,11 @@ class EcualizadorWidget extends StatefulWidget {
final PresetEcualizador preset;
final void Function(PresetEcualizador) onCambio;
const EcualizadorWidget({super.key, required this.preset, required this.onCambio});
const EcualizadorWidget({
super.key,
required this.preset,
required this.onCambio,
});
@override
State<EcualizadorWidget> createState() => _EcualizadorWidgetState();
@@ -35,7 +39,9 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
void _actualizarBanda(int index, double valor) {
setState(() => _bandas[index] = valor);
widget.onCambio(PresetEcualizador(nombre: 'Personalizado', bandas: List.from(_bandas)));
widget.onCambio(
PresetEcualizador(nombre: 'Personalizado', bandas: List.from(_bandas)),
);
}
@override
@@ -52,11 +58,20 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
children: [
Row(
children: [
Text(l10n.equalizerTitle, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
Text(
l10n.equalizerTitle,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
Chip(
label: Text(_nombrePreset(l10n, widget.preset.nombre), style: theme.textTheme.labelMedium),
backgroundColor: theme.colorScheme.secondaryContainer.withValues(alpha: 0.75),
label: Text(
_nombrePreset(l10n, widget.preset.nombre),
style: theme.textTheme.labelMedium,
),
backgroundColor: theme.colorScheme.secondaryContainer
.withValues(alpha: 0.75),
),
],
),
@@ -68,11 +83,18 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
for (int i = 0; i < 5; i++)
Expanded(
child: Card(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.35,
),
margin: const EdgeInsets.symmetric(horizontal: 4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 4,
),
child: Column(
children: [
SizedBox(
@@ -80,7 +102,9 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
child: Semantics(
slider: true,
label: l10n.equalizerBandLabel(_etiquetas[i]),
value: l10n.equalizerBandValue(_bandas[i].toStringAsFixed(1)),
value: l10n.equalizerBandValue(
_bandas[i].toStringAsFixed(1),
),
child: RotatedBox(
quarterTurns: 3,
child: Slider(
@@ -93,10 +117,15 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
),
),
),
Text('${_bandas[i].toStringAsFixed(1)}dB', style: theme.textTheme.labelSmall),
Text(
'${_bandas[i].toStringAsFixed(1)}dB',
style: theme.textTheme.labelSmall,
),
Text(
_etiquetas[i],
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
@@ -142,17 +171,19 @@ class PresetsEcualizadorWidget extends StatelessWidget {
return Wrap(
spacing: 8,
runSpacing: 6,
children: PresetEcualizador.presets.map((p) {
final selected = p.nombre == presetActual.nombre;
return ChoiceChip(
label: Text(_nombrePreset(l10n, p.nombre)),
selected: selected,
showCheckmark: false,
selectedColor: theme.colorScheme.primaryContainer,
backgroundColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.32),
onSelected: (_) => onSeleccionar(p),
);
}).toList(),
children:
PresetEcualizador.presets.map((p) {
final selected = p.nombre == presetActual.nombre;
return ChoiceChip(
label: Text(_nombrePreset(l10n, p.nombre)),
selected: selected,
showCheckmark: false,
selectedColor: theme.colorScheme.primaryContainer,
backgroundColor: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.32),
onSelected: (_) => onSeleccionar(p),
);
}).toList(),
);
}
}
+47 -40
View File
@@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_glass_surface.dart';
@@ -72,29 +72,32 @@ class _PluriNavButton extends StatelessWidget {
margin: EdgeInsets.symmetric(horizontal: selected ? 3 : 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
gradient: selected
? LinearGradient(
colors: [
t.electricMagenta.withValues(alpha: 0.32),
t.warmCoral.withValues(alpha: 0.18),
],
)
: null,
gradient:
selected
? LinearGradient(
colors: [
t.electricMagenta.withValues(alpha: 0.32),
t.warmCoral.withValues(alpha: 0.18),
],
)
: null,
border: Border.all(
color: selected
? Colors.white.withValues(alpha: 0.22)
: Colors.white.withValues(alpha: 0.06),
color:
selected
? Colors.white.withValues(alpha: 0.22)
: Colors.white.withValues(alpha: 0.06),
),
boxShadow: selected
? [
BoxShadow(
color: t.glowColor.withValues(alpha: 0.36),
blurRadius: 24,
spreadRadius: -6,
offset: const Offset(0, 8),
),
]
: const [],
boxShadow:
selected
? [
BoxShadow(
color: t.glowColor.withValues(alpha: 0.36),
blurRadius: 24,
spreadRadius: -6,
offset: const Offset(0, 8),
),
]
: const [],
),
child: InkWell(
borderRadius: BorderRadius.circular(999),
@@ -113,30 +116,34 @@ class _PluriNavButton extends StatelessWidget {
curve: Curves.easeOutBack,
child: PluriIcon(
glyph: item.glyph,
variant: selected
? PluriIconVariant.activeGlow
: PluriIconVariant.filled,
variant:
selected
? PluriIconVariant.activeGlow
: PluriIconVariant.filled,
size: selected ? 42 : 34,
),
),
AnimatedSize(
duration: context.pluriMotion.quick,
curve: Curves.easeOutCubic,
child: selected
? Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w900,
letterSpacing: -0.2,
),
),
)
: const SizedBox.shrink(),
child:
selected
? Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w900,
letterSpacing: -0.2,
),
),
)
: const SizedBox.shrink(),
),
],
),
+2 -1
View File
@@ -63,7 +63,8 @@ class PluriIcon extends StatelessWidget {
: icon;
return Semantics(
label: semanticLabel ?? _fallbackLabel(AppLocalizations.of(context), glyph),
label:
semanticLabel ?? _fallbackLabel(AppLocalizations.of(context), glyph),
image: true,
child: ExcludeSemantics(child: child),
);