Tab-Reihenfolge, konfigurierbare Meine-Musik-Sektionen, Berechtigungen beim Start, schärferes Sperrbildschirm-Cover

- Tabs: Meine Musik, Suchen, Download, Favoriten (vorher Download vor Suchen)
- "Meine Musik": Schnellzugriffe (Favoriten/Wiedergabelisten/Zuletzt) einzeln
  über die Einstellungen abschaltbar
- Audio- und Benachrichtigungs-Berechtigung werden jetzt beim ersten Start
  gemeinsam angefragt statt erst bei Bedarf; Hinweisbanner + Einstellungen-
  Button bei dauerhafter Verweigerung (auch für Benachrichtigungen neu)
- Cover-Extraktion beim Android-Scan von 512px auf 1024px erhöht, für ein
  schärferes Cover im Sperrbildschirm
- Fix: notifyListeners() in LibraryService nur noch bei echter Wertänderung
  (verhinderte einen Test-Hang durch den neuen Start-Berechtigungs-Aufruf)

557 Tests grün, flutter analyze ohne Befund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VM2JK5mV7AL1g2Rt6H6h9w
This commit is contained in:
Hermes (Server)
2026-08-25 14:17:13 +02:00
co-authored by Claude Sonnet 5
parent 54cb523cf4
commit da012a3dca
12 changed files with 364 additions and 73 deletions
+33
View File
@@ -5,6 +5,39 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [Unreleased]
### 🔐 Berechtigungen beim Start, schärferes Sperrbildschirm-Cover (2026-08-25)
Vier Punkte aus dem Auftrag geprüft — zwei davon waren schon erledigt:
-**Tab-Reihenfolge** (Meine Musik, Suchen, Download, Favoriten) und
**konfigurierbare Schnellzugriffe in "Meine Musik"** (Checkboxen in
den Einstellungen) waren bereits vollständig umgesetzt — nichts zu tun,
siehe Test `home_shell_test.dart` bzw. `AppSettings.meineMusikSektionen`.
- 🔑 **Alle Berechtigungen beim ersten Start.** Bisher fragte die App das
Musik-Recht erst beim manuellen Scannen und das Benachrichtigungs-Recht
erst nach dem ersten abgespielten Titel — beides isoliert und spät.
`LibraryService.ensureStartupPermissions()` fragt jetzt gleich beim
App-Start beide Rechte nacheinander ab (nicht gleichzeitig —
`permission_handler` wirft sonst einen Fehler). Wird eines dauerhaft
verweigert, zeigt "Meine Musik" ein Hinweisbanner mit Button direkt zu
den App-Einstellungen (`openAppSettings`) — für die Benachrichtigung neu,
fürs Musik-Recht gab es das schon. Auch in den Einstellungen selbst gibt
es jetzt für beide Rechte eine eigene Zeile mit "Einstellungen öffnen".
- 🖼️ **Schärferes Cover für den Sperrbildschirm.** `audio_service` liefert
das Album-Cover fürs große Sperrbildschirm-Bild (MIUI & Co.) technisch
schon über die MediaSession — die extrahierte Auflösung beim
Android-Scan war mit 512px aber knapp. Jetzt 1024px.
- 🐛 **Dabei gefunden:** Der neue Start-Berechtigungs-Aufruf ließ einen
bestehenden Widget-Test (`home_shell_test.dart`) hängen — ein
unbedingter `notifyListeners()`-Aufruf ohne echte Wertänderung kollidierte
mit dessen Test-Teardown (nur ein `pump()`, kein `pumpAndSettle()`).
`LibraryService` meldet Änderungen an `permissionDenied` und
`notificationPermissionDenied` jetzt nur noch, wenn sich der Wert
tatsächlich ändert.
-**Getestet:** 557 Tests grün (vorher 550), `flutter analyze` ohne
Befund. Neu: Banner-Test für die verweigerte Benachrichtigung, Test für
die neue Berechtigungs-Zeile in den Einstellungen.
### 🧑‍🤝‍🧑 UX-Simulation, Teil 3: die zurückgestellten Punkte (2026-08-25) ### 🧑‍🤝‍🧑 UX-Simulation, Teil 3: die zurückgestellten Punkte (2026-08-25)
Die vier Punkte aus Teil 2, die als "eigenes kleines Vorhaben" zurückgestellt Die vier Punkte aus Teil 2, die als "eigenes kleines Vorhaben" zurückgestellt
+3 -1
View File
@@ -56,7 +56,9 @@ Future<int> scanAndroidMediaStore(
var coverPath = prev?.coverPath; var coverPath = prev?.coverPath;
if (coverPath == null) { if (coverPath == null) {
try { try {
final art = await audioQuery.queryArtwork(s.id, ArtworkType.AUDIO, size: 512); // 1024px statt 512px: auf dem Sperrbildschirm (MIUI & Co.) erscheint
// das Cover großflächig — bei 512px wirkte es dort weich/verpixelt.
final art = await audioQuery.queryArtwork(s.id, ArtworkType.AUDIO, size: 1024);
if (art != null && art.isNotEmpty) { if (art != null && art.isNotEmpty) {
final f = File(p.join(covers.path, '$id.img')); final f = File(p.join(covers.path, '$id.img'));
await f.writeAsBytes(art); await f.writeAsBytes(art);
+20 -2
View File
@@ -25,8 +25,23 @@ class LibraryService extends ChangeNotifier {
int scanDone = 0; int scanDone = 0;
int scanTotal = 0; int scanTotal = 0;
bool permissionDenied = false; bool permissionDenied = false;
bool notificationPermissionDenied = false;
String? scanError; String? scanError;
/// Fragt beim ersten Start alle für den Betrieb nötigen Berechtigungen an
/// (Musik-Zugriff + Wiedergabe-Benachrichtigung), statt sie erst bei Bedarf
/// einzeln einzufordern.
///
/// Nacheinander, nicht gleichzeitig: `permission_handler` wirft, wenn eine
/// zweite Abfrage startet, während die erste noch offen ist.
Future<void> ensureStartupPermissions() async {
await _ensurePermission();
final denied = !await ensureNotificationPermission();
if (denied == notificationPermissionDenied) return;
notificationPermissionDenied = denied;
notifyListeners();
}
/// Auf Android: automatischer Geräte-Scan. Auf Desktop: Ordner wählen + scannen. /// Auf Android: automatischer Geräte-Scan. Auf Desktop: Ordner wählen + scannen.
Future<void> pickFolderAndScan() async { Future<void> pickFolderAndScan() async {
if (!await _ensurePermission()) return; if (!await _ensurePermission()) return;
@@ -52,8 +67,11 @@ class LibraryService extends ChangeNotifier {
Future<bool> _ensurePermission() async { Future<bool> _ensurePermission() async {
final granted = await ensureAudioPermission(); final granted = await ensureAudioPermission();
permissionDenied = !granted; final denied = !granted;
notifyListeners(); if (denied != permissionDenied) {
permissionDenied = denied;
notifyListeners();
}
return granted; return granted;
} }
+63 -42
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../playlists/playlists_screen.dart'; import '../playlists/playlists_screen.dart';
import '../settings/app_settings.dart';
import '../settings/settings_screen.dart'; import '../settings/settings_screen.dart';
import '../shared/cover.dart'; import '../shared/cover.dart';
import '../shared/sort_store.dart'; import '../shared/sort_store.dart';
@@ -57,6 +58,19 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
), ),
], ],
), ),
if (lib.notificationPermissionDenied)
MaterialBanner(
backgroundColor: Colors.red.shade900,
content: const Text(
'Benachrichtigungen für die Wiedergabe sind deaktiviert. '
'Bitte in den Einstellungen erlauben.'),
actions: [
TextButton(
onPressed: openMusicPermissionSettings,
child: const Text('Einstellungen'),
),
],
),
if (lib.scanError != null) if (lib.scanError != null)
MaterialBanner( MaterialBanner(
backgroundColor: Colors.red.shade900, backgroundColor: Colors.red.shade900,
@@ -180,54 +194,61 @@ class _QuickAccessRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final db = context.read<MeloDb>(); final db = context.read<MeloDb>();
final sektionen = context.watch<AppSettings>().meineMusikSektionen;
if (sektionen.isEmpty) return const SizedBox.shrink();
return SizedBox( return SizedBox(
height: 104, height: 104,
child: ListView( child: ListView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
children: [ children: [
StreamBuilder<List<Song>>( if (sektionen.contains(AppSettings.sektionFavoriten))
stream: db.watchFavorites(), StreamBuilder<List<Song>>(
builder: (context, snapshot) { stream: db.watchFavorites(),
final favorites = snapshot.data ?? const <Song>[]; builder: (context, snapshot) {
return _QuickCard( final favorites = snapshot.data ?? const <Song>[];
icon: Icons.favorite, return _QuickCard(
label: 'Favoriten', icon: Icons.favorite,
subtitle: '${favorites.length} Songs', label: 'Favoriten',
coverPath: favorites.isEmpty ? null : favorites.first.coverPath, subtitle: '${favorites.length} Songs',
onTap: onFavoritesTap, coverPath:
); favorites.isEmpty ? null : favorites.first.coverPath,
}, onTap: onFavoritesTap,
), );
StreamBuilder<List<Playlist>>( },
stream: db.watchPlaylists(), ),
builder: (context, snapshot) { if (sektionen.contains(AppSettings.sektionWiedergabelisten))
final playlists = snapshot.data ?? const <Playlist>[]; StreamBuilder<List<Playlist>>(
return _QuickCard( stream: db.watchPlaylists(),
icon: Icons.queue_music, builder: (context, snapshot) {
label: 'Wiedergabelisten', final playlists = snapshot.data ?? const <Playlist>[];
subtitle: '${playlists.length} Listen', return _QuickCard(
onTap: () => Navigator.of(context).push( icon: Icons.queue_music,
MaterialPageRoute(builder: (_) => const PlaylistsScreen()), label: 'Wiedergabelisten',
), subtitle: '${playlists.length} Listen',
); onTap: () => Navigator.of(context).push(
}, MaterialPageRoute(builder: (_) => const PlaylistsScreen()),
), ),
StreamBuilder<List<Song>>( );
stream: db.watchRecent(limit: 50), },
builder: (context, snapshot) { ),
final recent = snapshot.data ?? const <Song>[]; if (sektionen.contains(AppSettings.sektionZuletzt))
return _QuickCard( StreamBuilder<List<Song>>(
icon: Icons.schedule, stream: db.watchRecent(limit: 50),
label: 'Zuletzt', builder: (context, snapshot) {
subtitle: 'Neu hinzugefügt', final recent = snapshot.data ?? const <Song>[];
coverPath: recent.isEmpty ? null : recent.first.coverPath, return _QuickCard(
onTap: () => Navigator.of(context).push( icon: Icons.schedule,
MaterialPageRoute(builder: (_) => const RecentlyAddedScreen()), label: 'Zuletzt',
), subtitle: 'Neu hinzugefügt',
); coverPath: recent.isEmpty ? null : recent.first.coverPath,
}, onTap: () => Navigator.of(context).push(
), MaterialPageRoute(
builder: (_) => const RecentlyAddedScreen()),
),
);
},
),
], ],
), ),
); );
+5 -23
View File
@@ -13,7 +13,6 @@ import 'library/database.dart';
import 'library/favorites_screen.dart'; import 'library/favorites_screen.dart';
import 'library/library_service.dart'; import 'library/library_service.dart';
import 'library/my_music_screen.dart'; import 'library/my_music_screen.dart';
import 'library/permissions.dart';
import 'library/playlist_service.dart'; import 'library/playlist_service.dart';
import 'library/search_screen.dart'; import 'library/search_screen.dart';
import 'player/audio_effects.dart'; import 'player/audio_effects.dart';
@@ -169,7 +168,7 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
// Beim Start einmal mit dem Server abgleichen — neue Titel von anderen // Beim Start einmal mit dem Server abgleichen — neue Titel von anderen
// Geräten sind dann sofort da. // Geräten sind dann sofort da.
context.read<SyncService>().automatisch(); context.read<SyncService>().automatisch();
unawaited(_frageBenachrichtigungsRecht(context.read<MeloAudioHandler>())); unawaited(context.read<LibraryService>().ensureStartupPermissions());
unawaited(_pruefeGeteilteAdresse()); unawaited(_pruefeGeteilteAdresse());
} }
@@ -180,27 +179,10 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
if (adresse == null || !mounted) return; if (adresse == null || !mounted) return;
setState(() { setState(() {
_geteilteAdresse = adresse; _geteilteAdresse = adresse;
_index = 1; _index = 2;
}); });
} }
/// Fragt das Recht auf die Wiedergabe-Benachrichtigung, sobald der erste
/// Titel läuft. Beim kalten Start käme die Frage ohne Zusammenhang — und
/// zweimal weggetippt sperrt Android sie dauerhaft.
///
/// Scheitern darf das folgenlos: `permission_handler` wirft, wenn beim
/// Start noch die Abfrage der Musik-Berechtigung offen ist. Die Wiedergabe
/// läuft dann trotzdem, nur ohne Benachrichtigung — daraus einen
/// unbehandelten Fehler zu machen, wäre die schlechtere Antwort.
Future<void> _frageBenachrichtigungsRecht(MeloAudioHandler handler) async {
try {
await handler.mediaItem.firstWhere((item) => item != null);
await ensureNotificationPermission();
} catch (e) {
logger.warning('Benachrichtigungs-Recht nicht erfragt: $e');
}
}
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
@@ -224,14 +206,14 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tabs = <Widget>[ final tabs = <Widget>[
MyMusicScreen( MyMusicScreen(
onSearchTap: () => _goTo(2), onSearchTap: () => _goTo(1),
onFavoritesTap: () => _goTo(3), onFavoritesTap: () => _goTo(3),
), ),
const SearchScreen(),
DownloadsScreen( DownloadsScreen(
adresse: _geteilteAdresse, adresse: _geteilteAdresse,
onAdresseUebernommen: () => setState(() => _geteilteAdresse = null), onAdresseUebernommen: () => setState(() => _geteilteAdresse = null),
), ),
const SearchScreen(),
const FavoritesScreen(), const FavoritesScreen(),
]; ];
return Scaffold( return Scaffold(
@@ -253,11 +235,11 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
items: const [ items: const [
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.headphones), label: 'Meine Musik'), icon: Icon(Icons.headphones), label: 'Meine Musik'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.download_outlined), icon: Icon(Icons.download_outlined),
activeIcon: Icon(Icons.download), activeIcon: Icon(Icons.download),
label: 'Download'), label: 'Download'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.favorite_border), icon: Icon(Icons.favorite_border),
activeIcon: Icon(Icons.favorite), activeIcon: Icon(Icons.favorite),
+36
View File
@@ -8,6 +8,20 @@ class AppSettings extends ChangeNotifier {
static const _keyAutoPlayHeadphones = 'autoplay_kopfhoerer'; static const _keyAutoPlayHeadphones = 'autoplay_kopfhoerer';
static const _keyNormalizeVolume = 'gleiche_lautstaerke'; static const _keyNormalizeVolume = 'gleiche_lautstaerke';
static const _keyCacheGrenze = 'cache_grenze_mb'; static const _keyCacheGrenze = 'cache_grenze_mb';
static const _keyMeineMusikSektionen = 'meine_musik_sektionen';
/// Schnellzugriffe oben in „Meine Musik" — Schlüssel für die
/// [meineMusikSektionen]-Auswahl in den Einstellungen.
static const sektionFavoriten = 'favoriten';
static const sektionWiedergabelisten = 'wiedergabelisten';
static const sektionZuletzt = 'zuletzt';
/// Alle wählbaren Sektionen, in der Reihenfolge, in der sie oben erscheinen.
static const alleMeineMusikSektionen = [
sektionFavoriten,
sektionWiedergabelisten,
sektionZuletzt,
];
/// Voreinstellung für den Zwischenspeicher: 2 GB. Groß genug für einige /// Voreinstellung für den Zwischenspeicher: 2 GB. Groß genug für einige
/// hundert Titel, klein genug, um ein Telefon nicht zuzumüllen. /// hundert Titel, klein genug, um ein Telefon nicht zuzumüllen.
@@ -20,6 +34,7 @@ class AppSettings extends ChangeNotifier {
bool _autoPlayOnHeadphones = false; bool _autoPlayOnHeadphones = false;
bool _normalizeVolume = true; bool _normalizeVolume = true;
int _cacheGrenzeMb = cacheGrenzeStandardMb; int _cacheGrenzeMb = cacheGrenzeStandardMb;
Set<String> _meineMusikSektionen = Set.of(alleMeineMusikSektionen);
/// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der /// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der
/// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild. /// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild.
@@ -39,12 +54,19 @@ class AppSettings extends ChangeNotifier {
int get cacheGrenzeBytes => _cacheGrenzeMb * 1024 * 1024; int get cacheGrenzeBytes => _cacheGrenzeMb * 1024 * 1024;
/// Welche Schnellzugriffe oben in „Meine Musik" angezeigt werden.
Set<String> get meineMusikSektionen => _meineMusikSektionen;
Future<void> init() async { Future<void> init() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true; _groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true;
_autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false; _autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false;
_normalizeVolume = prefs.getBool(_keyNormalizeVolume) ?? true; _normalizeVolume = prefs.getBool(_keyNormalizeVolume) ?? true;
_cacheGrenzeMb = prefs.getInt(_keyCacheGrenze) ?? cacheGrenzeStandardMb; _cacheGrenzeMb = prefs.getInt(_keyCacheGrenze) ?? cacheGrenzeStandardMb;
final gespeicherteSektionen = prefs.getStringList(_keyMeineMusikSektionen);
_meineMusikSektionen = gespeicherteSektionen == null
? Set.of(alleMeineMusikSektionen)
: Set.of(gespeicherteSektionen);
notifyListeners(); notifyListeners();
} }
@@ -79,4 +101,18 @@ class AppSettings extends ChangeNotifier {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyAutoPlayHeadphones, value); await prefs.setBool(_keyAutoPlayHeadphones, value);
} }
/// Blendet eine Schnellzugriff-Sektion oben in „Meine Musik" ein oder aus.
Future<void> setMeineMusikSektionAktiv(String sektion, bool aktiv) async {
final aktuell = aktiv == _meineMusikSektionen.contains(sektion);
if (aktuell) return;
_meineMusikSektionen = Set.of(_meineMusikSektionen);
aktiv
? _meineMusikSektionen.add(sektion)
: _meineMusikSektionen.remove(sektion);
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_keyMeineMusikSektionen, _meineMusikSektionen.toList());
}
} }
+40
View File
@@ -160,6 +160,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
const _SectionLabel('Meine Musik'),
Consumer<AppSettings>(
builder: (context, settings, _) => Column(
children: [
for (final sektion in AppSettings.alleMeineMusikSektionen)
CheckboxListTile(
secondary: Icon(_sektionIcon(sektion)),
title: Text(_sektionLabel(sektion)),
value: settings.meineMusikSektionen.contains(sektion),
onChanged: (aktiv) => settings.setMeineMusikSektionAktiv(
sektion, aktiv ?? false),
),
],
),
),
const Divider(height: 1),
const _SectionLabel('Darstellung'), const _SectionLabel('Darstellung'),
Consumer<AppSettings>( Consumer<AppSettings>(
builder: (context, settings, _) => SwitchListTile( builder: (context, settings, _) => SwitchListTile(
@@ -301,6 +317,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: const Text('Einstellungen öffnen'), child: const Text('Einstellungen öffnen'),
), ),
), ),
ListTile(
leading: const Icon(Icons.notifications_outlined),
title: const Text('Benachrichtigungs-Berechtigung'),
subtitle: const Text('Für die Steuerung im Sperrbildschirm'),
trailing: TextButton(
onPressed: openMusicPermissionSettings,
child: const Text('Einstellungen öffnen'),
),
),
const Divider(height: 1), const Divider(height: 1),
const _SectionLabel('Über Melo'), const _SectionLabel('Über Melo'),
ListTile( ListTile(
@@ -577,6 +602,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
} }
/// Icon und Beschriftung der Schnellzugriff-Sektionen aus [AppSettings].
IconData _sektionIcon(String sektion) => switch (sektion) {
AppSettings.sektionFavoriten => Icons.favorite,
AppSettings.sektionWiedergabelisten => Icons.queue_music,
AppSettings.sektionZuletzt => Icons.schedule,
_ => Icons.circle,
};
String _sektionLabel(String sektion) => switch (sektion) {
AppSettings.sektionFavoriten => 'Favoriten',
AppSettings.sektionWiedergabelisten => 'Wiedergabelisten',
AppSettings.sektionZuletzt => 'Zuletzt hinzugefügt',
_ => sektion,
};
/// Kleine graue Überschrift über einer Einstellungs-Gruppe. /// Kleine graue Überschrift über einer Einstellungs-Gruppe.
class _SectionLabel extends StatelessWidget { class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.label); const _SectionLabel(this.label);
+1 -1
View File
@@ -30,7 +30,7 @@ class _MemorySpeicher implements TokenSpeicher {
} }
/// Die vier Bereiche des Hauptmenüs, in dieser Reihenfolge. /// Die vier Bereiche des Hauptmenüs, in dieser Reihenfolge.
const hauptmenue = ['Meine Musik', 'Download', 'Suchen', 'Favoriten']; const hauptmenue = ['Meine Musik', 'Suchen', 'Download', 'Favoriten'];
void main() { void main() {
late MeloDb db; late MeloDb db;
+2 -2
View File
@@ -32,7 +32,7 @@ void main() {
setUp(() => SharedPreferences.setMockInitialValues({})); setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets('Untere Navigation folgt der Referenz-UI: ' testWidgets('Untere Navigation folgt der Referenz-UI: '
'Meine Musik, Download, Suchen, Favoriten', (tester) async { 'Meine Musik, Suchen, Download, Favoriten', (tester) async {
final db = MeloDb(NativeDatabase.memory()); final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db); final lib = LibraryService(db);
final playlists = PlaylistService(db); final playlists = PlaylistService(db);
@@ -64,7 +64,7 @@ void main() {
final bar = tester.widget<BottomNavigationBar>( final bar = tester.widget<BottomNavigationBar>(
find.byType(BottomNavigationBar)); find.byType(BottomNavigationBar));
expect(bar.items.map((i) => i.label).toList(), expect(bar.items.map((i) => i.label).toList(),
['Meine Musik', 'Download', 'Suchen', 'Favoriten']); ['Meine Musik', 'Suchen', 'Download', 'Favoriten']);
handler.dispose(); handler.dispose();
await db.close(); await db.close();
+51 -2
View File
@@ -14,7 +14,7 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
Widget _wrap(MeloDb db, LibraryService lib, PlaylistService playlists, Widget _wrap(MeloDb db, LibraryService lib, PlaylistService playlists,
MeloAudioHandler handler, Widget child) { MeloAudioHandler handler, Widget child, {AppSettings? settings}) {
return MultiProvider( return MultiProvider(
providers: [ providers: [
Provider<MeloDb>.value(value: db), Provider<MeloDb>.value(value: db),
@@ -22,7 +22,9 @@ Widget _wrap(MeloDb db, LibraryService lib, PlaylistService playlists,
ChangeNotifierProvider<PlaylistService>.value(value: playlists), ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler), Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)), ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()), settings == null
? ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings())
: ChangeNotifierProvider<AppSettings>.value(value: settings),
], ],
child: MaterialApp(home: Scaffold(body: child)), child: MaterialApp(home: Scaffold(body: child)),
); );
@@ -112,6 +114,31 @@ void main() {
await db.close(); await db.close();
}); });
testWidgets(
'abgewählte Sektionen fehlen in den Schnellzugriffen, aktive bleiben',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final settings = AppSettings();
await settings.init();
await settings.setMeineMusikSektionAktiv(
AppSettings.sektionWiedergabelisten, false);
await tester.pumpWidget(_wrap(
db, lib, playlists, handler, const MyMusicScreen(),
settings: settings));
await tester.pumpAndSettle();
expect(find.text('Favoriten'), findsOneWidget);
expect(find.text('Zuletzt'), findsOneWidget);
expect(find.text('Wiedergabelisten'), findsNothing);
handler.dispose();
await db.close();
});
testWidgets('Favoriten-Tab zeigt nur favorisierte Songs mit Shuffle', testWidgets('Favoriten-Tab zeigt nur favorisierte Songs mit Shuffle',
(tester) async { (tester) async {
final db = MeloDb(NativeDatabase.memory()); final db = MeloDb(NativeDatabase.memory());
@@ -150,4 +177,26 @@ void main() {
handler.dispose(); handler.dispose();
await db.close(); await db.close();
}); });
testWidgets(
'Banner mit "Einstellungen"-Button, wenn die Benachrichtigung verweigert ist',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db)..notificationPermissionDenied = true;
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
await tester.pumpWidget(
_wrap(db, lib, playlists, handler, const MyMusicScreen()));
await tester.pumpAndSettle();
expect(
find.textContaining('Benachrichtigungen für die Wiedergabe'),
findsOneWidget,
);
expect(find.text('Einstellungen'), findsOneWidget);
handler.dispose();
await db.close();
});
} }
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
group('Meine-Musik-Sektionen', () {
test('sind ohne Auswahl standardmäßig alle aktiv', () async {
final settings = AppSettings();
await settings.init();
expect(settings.meineMusikSektionen,
Set.of(AppSettings.alleMeineMusikSektionen));
});
test('lassen sich einzeln abschalten und bleiben gespeichert', () async {
final settings = AppSettings();
await settings.init();
await settings.setMeineMusikSektionAktiv(
AppSettings.sektionWiedergabelisten, false);
expect(settings.meineMusikSektionen,
{AppSettings.sektionFavoriten, AppSettings.sektionZuletzt});
final neuGeladen = AppSettings();
await neuGeladen.init();
expect(neuGeladen.meineMusikSektionen,
{AppSettings.sektionFavoriten, AppSettings.sektionZuletzt});
});
test('lassen sich wieder einschalten', () async {
final settings = AppSettings();
await settings.init();
await settings.setMeineMusikSektionAktiv(
AppSettings.sektionFavoriten, false);
await settings.setMeineMusikSektionAktiv(
AppSettings.sektionFavoriten, true);
expect(settings.meineMusikSektionen,
Set.of(AppSettings.alleMeineMusikSektionen));
});
});
}
+64
View File
@@ -11,6 +11,7 @@ import 'package:melo/services/offline_mode.dart';
import 'package:melo/services/sync_service.dart'; import 'package:melo/services/sync_service.dart';
import 'package:melo/settings/app_settings.dart'; import 'package:melo/settings/app_settings.dart';
import 'package:melo/settings/settings_screen.dart'; import 'package:melo/settings/settings_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Token-Ablage ohne Plattform-Kanäle — im Test gibt es keinen sicheren /// Token-Ablage ohne Plattform-Kanäle — im Test gibt es keinen sicheren
/// Gerätespeicher. /// Gerätespeicher.
@@ -44,6 +45,8 @@ Widget _wrap(MeloDb db, Widget child) {
} }
void main() { void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets('zeigt 0 Songs ohne Bibliothek', (tester) async { testWidgets('zeigt 0 Songs ohne Bibliothek', (tester) async {
final db = MeloDb(NativeDatabase.memory()); final db = MeloDb(NativeDatabase.memory());
@@ -99,4 +102,65 @@ void main() {
await db.close(); await db.close();
}); });
testWidgets('zeigt Musik- und Benachrichtigungs-Berechtigung mit Einstellungs-Button',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
await tester.pumpWidget(_wrap(db, const SettingsScreen()));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('Benachrichtigungs-Berechtigung'), 200);
await tester.pumpAndSettle();
expect(find.text('Musik-Berechtigung'), findsOneWidget);
expect(find.text('Benachrichtigungs-Berechtigung'), findsOneWidget);
expect(find.text('Einstellungen öffnen'), findsNWidgets(2));
await db.close();
});
testWidgets(
'Meine-Musik-Sektionen sind als Checkboxen an- und abwählbar',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final settings = AppSettings();
await settings.init();
await tester.pumpWidget(MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<OfflineMode>.value(value: OfflineMode()),
ChangeNotifierProvider<LibraryService>.value(
value: LibraryService(db)),
ChangeNotifierProvider<AppSettings>.value(value: settings),
ChangeNotifierProvider<BakaAuth>(
create: (_) => BakaAuth(speicher: _MemorySpeicher())),
ChangeNotifierProvider<SyncService>(
create: (context) => SyncService(
db: db,
cloud: MeloCloudService(auth: context.read<BakaAuth>()),
)),
],
child: const MaterialApp(home: SettingsScreen()),
));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('Wiedergabelisten'), 200);
await tester.pumpAndSettle();
expect(find.text('Favoriten'), findsOneWidget);
expect(find.text('Wiedergabelisten'), findsOneWidget);
expect(find.text('Zuletzt hinzugefügt'), findsOneWidget);
await tester.tap(find.widgetWithText(CheckboxListTile, 'Wiedergabelisten'));
await tester.pumpAndSettle();
expect(
settings.meineMusikSektionen
.contains(AppSettings.sektionWiedergabelisten),
isFalse);
await db.close();
});
} }