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:
co-authored by
Claude Sonnet 5
parent
54cb523cf4
commit
da012a3dca
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
if (denied != permissionDenied) {
|
||||||
|
permissionDenied = denied;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
}
|
||||||
return granted;
|
return granted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +194,15 @@ 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: [
|
||||||
|
if (sektionen.contains(AppSettings.sektionFavoriten))
|
||||||
StreamBuilder<List<Song>>(
|
StreamBuilder<List<Song>>(
|
||||||
stream: db.watchFavorites(),
|
stream: db.watchFavorites(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -194,11 +211,13 @@ class _QuickAccessRow extends StatelessWidget {
|
|||||||
icon: Icons.favorite,
|
icon: Icons.favorite,
|
||||||
label: 'Favoriten',
|
label: 'Favoriten',
|
||||||
subtitle: '${favorites.length} Songs',
|
subtitle: '${favorites.length} Songs',
|
||||||
coverPath: favorites.isEmpty ? null : favorites.first.coverPath,
|
coverPath:
|
||||||
|
favorites.isEmpty ? null : favorites.first.coverPath,
|
||||||
onTap: onFavoritesTap,
|
onTap: onFavoritesTap,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (sektionen.contains(AppSettings.sektionWiedergabelisten))
|
||||||
StreamBuilder<List<Playlist>>(
|
StreamBuilder<List<Playlist>>(
|
||||||
stream: db.watchPlaylists(),
|
stream: db.watchPlaylists(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -213,6 +232,7 @@ class _QuickAccessRow extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (sektionen.contains(AppSettings.sektionZuletzt))
|
||||||
StreamBuilder<List<Song>>(
|
StreamBuilder<List<Song>>(
|
||||||
stream: db.watchRecent(limit: 50),
|
stream: db.watchRecent(limit: 50),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -223,7 +243,8 @@ class _QuickAccessRow extends StatelessWidget {
|
|||||||
subtitle: 'Neu hinzugefügt',
|
subtitle: 'Neu hinzugefügt',
|
||||||
coverPath: recent.isEmpty ? null : recent.first.coverPath,
|
coverPath: recent.isEmpty ? null : recent.first.coverPath,
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => const RecentlyAddedScreen()),
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const RecentlyAddedScreen()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-23
@@ -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),
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user