feat(audio): audio session integration and runtime robustness

- Integrate audio_session (new servicio_audio_session.dart): incoming calls pause the radio and resume on end, headphone unplug pauses without auto-resume, permanent focus loss never auto-resumes, duck lowers volume
- Add play-intent flag to ServicioAudio so interruption handling and future reconnect logic can distinguish user pause from system-driven stops
- Eliminate read-modify-write race in ServicioAlarmas with an in-memory cache and single-writer queue across all mutations; recalcularTodas persists only when state actually changed
- Convert ServicioAlarmasAndroid static StreamController/handler to injectable instance fields, restoring test isolation
- Inject a single cached SharedPreferences from main.dart across services and state (removes 23 inline getInstance() calls)
- Move configurarLocalizaciones out of MiniReproductor.build() (was running on every rebuild during playback)
- Bound the alarm fire-dedup set (cap 200 entries, 24h pruning)
- 12 new tests (89 total green), flutter analyze clean
This commit is contained in:
2026-06-11 16:25:09 +02:00
parent f3e9487215
commit 079e19f0ee
21 changed files with 1059 additions and 151 deletions
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'package:provider/provider.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
class _EstadoRadioContador extends EstadoRadio {
_EstadoRadioContador()
: super(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
int llamadasConfigurar = 0;
@override
void configurarLocalizaciones(AppLocalizations l10n) {
llamadasConfigurar++;
super.configurarLocalizaciones(l10n);
}
}
/// S3-R3: `configurarLocalizaciones` must run once per locale change, not on
/// every rebuild triggered by playback notifications.
void main() {
testWidgets(
'configurarLocalizaciones corre una vez por locale, no por rebuild (S3-R3-A)',
(tester) async {
final estado = _EstadoRadioContador();
addTearDown(estado.dispose);
var locale = const Locale('es');
late StateSetter cambiarLocale;
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: StatefulBuilder(
builder: (context, setState) {
cambiarLocale = setState;
return MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MiniReproductor()),
);
},
),
),
);
await tester.pumpAndSettle();
// Ten rebuilds driven by playback state notifications.
for (var i = 0; i < 10; i++) {
estado.notifyListeners();
await tester.pump();
}
expect(
estado.llamadasConfigurar,
1,
reason: 'diez rebuilds con el mismo locale => una sola configuracion',
);
cambiarLocale(() => locale = const Locale('en'));
await tester.pumpAndSettle();
expect(
estado.llamadasConfigurar,
2,
reason: 'el cambio de locale debe reconfigurar exactamente una vez',
);
},
);
}