diff --git a/CHANGELOG.md b/CHANGELOG.md index 0827414..b2955b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,51 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🎨 Neue Melo-Oberfläche: 4 Tabs + Sortierung (2026-08-20) +- ✅ **Was:** Die App hat jetzt die von dir gewünschte Struktur mit + **4 Tabs: Meine Musik · Suche · Favoriten · Download**. "Meine Musik" ist + der neue Startbildschirm — **ohne Topbar**, wie du es wolltest. +- 🎵 **Meine Musik:** Oben eine Kopfzeile mit Einstellungs-Icon (links), + Suchfeld (Mitte) und ?-Icon für die Musikerkennung (rechts, kommt später). + Darunter Schnellzugriff-Karten (Favoriten · Wiedergabelisten · Zuletzt · + Künstler · Alben), dann **Shuffle-Wiedergabe + Sortieren**, dann die + komplette Liederliste. +- ↕️ **Sortieren (neu!):** Über das Symbol rechts neben "Shuffle-Wiedergabe" + kannst du wählen zwischen **Zuletzt hinzugefügt**, **Name (A–Z–#)** und + **Wie oft abgespielt** — jeweils auf- oder absteigend. Die Wahl wird + gespeichert und überlebt einen Neustart. Bei "Name" stehen Buchstaben + vorne und Ziffern/Sonderzeichen hinten unter "#"; Umlaute sortieren + richtig ein (Ärger steht bei A, nicht hinter Z). +- ❤️ **Favoriten-Tab:** eigener Tab mit Shuffle-Wiedergabe und **genau + derselben Sortierung** — mit eigener, getrennt gespeicherter Einstellung. +- ⬇️ **Download-Tab:** durchsucht den verbundenen Navidrome-Server + (Alben/Künstler); abgespielte Titel werden weiterhin automatisch lokal + zwischengespeichert. +- 🔢 **"Wie oft abgespielt" zählt jetzt wirklich:** Neue Spalte `playCount` + in der Datenbank (Schema 3, bestehende Bibliotheken werden automatisch + migriert). Gezählt wird beim Start eines Titels — nicht sekündlich, + solange er läuft. +- 🎨 **Farben:** Schwarz auf **#0B0B10** und Melo-Rot **#c0392b** (vorher ein + helleres Rot). +- 🔧 **Details:** + - Neu: `library/song_sort.dart` (reine Sortier-Logik, 11 Tests), + `shared/sort_store.dart` (Speichern der Wahl, 4 Tests), + `shared/sortable_song_list.dart` (Shuffle + Sortier-Menü), + `library/my_music_screen.dart`, `library/favorites_screen.dart`, + `downloads/downloads_screen.dart`. + - Der alte `library_screen.dart` (Tabs Alle/Künstler/Alben/Server) ist + entfallen — seine Inhalte leben jetzt in "Meine Musik" (Alle), + als eigene Bildschirme (Künstler/Alben) und im Download-Tab (Server). + - **Musik hinzufügen** und **Erneut scannen** saßen in der entfernten + Topbar und stehen jetzt in den **Einstellungen → Bibliothek** + (zusätzlich weiterhin im leeren Zustand von "Meine Musik"). + - Der Mini-Player ist jetzt auf allen Tabs sichtbar (vorher war der + Player ein eigener Tab). +- 🐛 **Nebenbei gefixt:** Das Sortier-Menü ist scrollbar — auf niedrigen + Bildschirmen bzw. im Querformat war der "Übernehmen"-Knopf sonst nicht + erreichbar. Schnellzugriff-Karten liefen um 2 Pixel über. +- ✅ **Getestet:** 107 Tests grün, `flutter analyze` ohne Befund. + ### 🔎 Login-Dialog zeigt jetzt die echte Server-Fehlermeldung (2026-08-20) - ✅ **Was:** Schlägt der Navidrome-Login fehl, zeigt der Dialog jetzt die Original-Meldung des Servers (z. B. „Wrong username or password") statt diff --git a/lib/library/library_screen.dart b/lib/downloads/downloads_screen.dart similarity index 51% rename from lib/library/library_screen.dart rename to lib/downloads/downloads_screen.dart index a9930ef..ffe0967 100644 --- a/lib/library/library_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -4,256 +4,42 @@ import 'package:provider/provider.dart'; import '../player/audio_handler.dart'; import '../services/navidrome_service.dart'; -import '../shared/cover.dart'; -import 'album_list.dart'; -import 'artist_list.dart'; -import 'database.dart'; -import 'library_service.dart'; -import 'permissions.dart'; -import 'song_list.dart'; -import 'song_media.dart'; -/// Bibliotheks-Tab mit drei Unteransichten: alle Songs, Künstler, Alben. -/// Scan-Banner/-Fortschritt gelten für alle Unteransichten und liegen daher -/// oberhalb der [TabBarView]. -class LibraryScreen extends StatelessWidget { - const LibraryScreen({super.key}); +/// Download-Tab: durchsucht den verbundenen Navidrome-Server. Abgespielte +/// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert +/// und stehen danach offline zur Verfügung. +class DownloadsScreen extends StatelessWidget { + const DownloadsScreen({super.key}); @override Widget build(BuildContext context) { - final lib = context.watch(); - return DefaultTabController( - length: 4, - child: Scaffold( - appBar: AppBar( - title: const Text('Bibliothek'), - actions: [ - IconButton( - tooltip: addMusicLabel, - icon: const Icon(Icons.create_new_folder_outlined), - onPressed: lib.scanning ? null : lib.pickFolderAndScan, - ), - IconButton( - tooltip: 'Erneut scannen', - icon: const Icon(Icons.refresh), - onPressed: lib.scanning ? null : lib.rescan, - ), - ], - bottom: const TabBar( - // Engere Abstände, damit '🌐 Server' nicht abgeschnitten wird - // (4 feste Tabs, gleich breit) — Schriftgröße bleibt unverändert. - labelPadding: EdgeInsets.symmetric(horizontal: 4), - tabs: [ - Tab(text: 'Alle'), - Tab(text: 'Künstler'), - Tab(text: 'Alben'), - Tab(text: '🌐 Server'), - ], - ), - ), - body: Column( - children: [ - if (lib.permissionDenied) - MaterialBanner( - backgroundColor: Colors.red.shade900, - content: const Text('Zugriff auf Musik verweigert. ' - 'Bitte in den Einstellungen erlauben.'), - actions: [ - TextButton( - onPressed: openMusicPermissionSettings, - child: const Text('Einstellungen'), - ), - ], - ), - if (lib.scanError != null) - MaterialBanner( - backgroundColor: Colors.red.shade900, - content: Text(lib.scanError!), - actions: [ - TextButton( - onPressed: lib.dismissScanError, - child: const Text('Verwerfen'), - ), - ], - ), - if (lib.scanning) - Padding( - padding: const EdgeInsets.all(12), - child: Column( - children: [ - const LinearProgressIndicator(), - const SizedBox(height: 6), - Text( - 'Scanne … ${lib.scanDone}' - '${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}', - style: const TextStyle(color: Colors.white54, fontSize: 12), - ), - ], - ), - ), - Expanded( - child: TabBarView( - children: [ - const _AllSongsTab(), - const ArtistListScreen(), - const AlbumListScreen(), - const _NavidromeTab(), - ], - ), - ), - ], - ), - ), - ); - } -} - -/// "Alle"-Tab: zuletzt hinzugefügt + vollständige Songliste (unverändertes -/// Verhalten aus der Zeit vor der Künstler-/Alben-Aufteilung). -class _AllSongsTab extends StatelessWidget { - const _AllSongsTab(); - - @override - Widget build(BuildContext context) { - final db = context.read(); - final lib = context.watch(); - return Column( - children: [ - StreamBuilder>( - stream: db.watchRecent(limit: 10), - builder: (context, snapshot) { - final recent = snapshot.data ?? const []; - if (recent.isEmpty || lib.scanning) return const SizedBox.shrink(); - return _RecentlyAdded(songs: recent); - }, - ), - Expanded( - child: StreamBuilder>( - stream: db.watchSongs(), - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Text('Fehler: ${snapshot.error}', - style: const TextStyle(color: Colors.white54)), - ); - } - final songs = snapshot.data ?? const []; - if (songs.isEmpty && !lib.scanning) { - return const _Empty(); - } - return SongList(songs); - }, - ), - ), - ], - ); - } -} - -/// Horizontal scrollende Leiste mit den zuletzt hinzugefügten Songs. -class _RecentlyAdded extends StatelessWidget { - const _RecentlyAdded({required this.songs}); - final List songs; - - @override - Widget build(BuildContext context) { - final handler = context.read(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text('Zuletzt hinzugefügt', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), - ), - SizedBox( - height: 140, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: songs.length, - itemBuilder: (context, i) { - final s = songs[i]; - return Padding( - padding: const EdgeInsets.only(right: 12), - child: SizedBox( - width: 96, - child: InkWell( - onTap: () async { - try { - await playSongs(handler, songs, i); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Wiedergabe fehlgeschlagen: $e')), - ); - } - } - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CoverImage( - artUri: - s.coverPath != null ? Uri.file(s.coverPath!) : null, - size: 96, - radius: 8, - ), - const SizedBox(height: 4), - Text( - s.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12), - ), - ], - ), - ), - ), - ); - }, - ), - ), - ], - ); - } -} - -class _Empty extends StatelessWidget { - const _Empty(); - - @override - Widget build(BuildContext context) { - final lib = context.read(); - return Center( + return const SafeArea( + bottom: false, child: Column( - mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.library_music, size: 64, color: Colors.white24), - const SizedBox(height: 12), - const Text('Noch keine Musik', - style: TextStyle(color: Colors.white54)), - const SizedBox(height: 16), - FilledButton.icon( - icon: const Icon(Icons.create_new_folder_outlined), - label: Text(addMusicLabel), - onPressed: lib.pickFolderAndScan, + Padding( + padding: EdgeInsets.fromLTRB(20, 16, 20, 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text('Download', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)), + ), ), + Expanded(child: _ServerBrowser()), ], ), ); } } -class _NavidromeTab extends StatefulWidget { - const _NavidromeTab(); +class _ServerBrowser extends StatefulWidget { + const _ServerBrowser(); @override - State<_NavidromeTab> createState() => _NavidromeTabState(); + State<_ServerBrowser> createState() => _ServerBrowserState(); } -class _NavidromeTabState extends State<_NavidromeTab> { +class _ServerBrowserState extends State<_ServerBrowser> { late final NavidromeService _nav = NavidromeService(); List _albums = []; List _artists = []; diff --git a/lib/library/album_list.dart b/lib/library/album_list.dart index de82dca..6139714 100644 --- a/lib/library/album_list.dart +++ b/lib/library/album_list.dart @@ -67,3 +67,16 @@ class _AlbumSongsScreen extends StatelessWidget { ); } } + +/// Alben-Übersicht als eigener Bildschirm (Schnellzugriff "Alben"). +class AlbumsScreen extends StatelessWidget { + const AlbumsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Alben')), + body: const AlbumListScreen(), + ); + } +} diff --git a/lib/library/artist_list.dart b/lib/library/artist_list.dart index acdd271..5d3be9b 100644 --- a/lib/library/artist_list.dart +++ b/lib/library/artist_list.dart @@ -59,3 +59,16 @@ class _ArtistSongsScreen extends StatelessWidget { ); } } + +/// Künstler-Übersicht als eigener Bildschirm (Schnellzugriff "Künstler"). +class ArtistsScreen extends StatelessWidget { + const ArtistsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Künstler')), + body: const ArtistListScreen(), + ); + } +} diff --git a/lib/library/database.dart b/lib/library/database.dart index 4eeb0b7..e87aa25 100644 --- a/lib/library/database.dart +++ b/lib/library/database.dart @@ -22,6 +22,10 @@ class Songs extends Table { IntColumn get updatedAtMs => integer()(); BoolColumn get deleted => boolean().withDefault(const Constant(false))(); + /// Wie oft der Song vollständig gestartet wurde — Grundlage für die + /// Sortierung "Wie oft abgespielt". + IntColumn get playCount => integer().withDefault(const Constant(0))(); + @override Set get primaryKey => {id}; } @@ -85,7 +89,7 @@ class MeloDb extends _$MeloDb { MeloDb([QueryExecutor? executor]) : super(executor ?? _open()); @override - int get schemaVersion => 2; + int get schemaVersion => 3; @override MigrationStrategy get migration => MigrationStrategy( @@ -97,6 +101,9 @@ class MeloDb extends _$MeloDb { await m.createTable(favorites); await m.createTable(playbackHistory); } + if (from < 3) { + await m.addColumn(songs, songs.playCount); + } }, ); @@ -291,6 +298,17 @@ class MeloDb extends _$MeloDb { } // === Playback History === + /// Zählt eine Wiedergabe. Wird beim Start eines Tracks aufgerufen, nicht beim + /// periodischen Speichern der Position — sonst würde der Zähler hochlaufen, + /// solange ein Song nur läuft. + Future incrementPlayCount(String songId) async { + await customUpdate( + 'UPDATE songs SET play_count = play_count + 1 WHERE id = ?', + variables: [Variable(songId)], + updates: {songs}, + ); + } + Future recordPlayback(String songId, int positionMs) async { await into(playbackHistory).insert(PlaybackHistoryCompanion.insert( songId: songId, diff --git a/lib/library/database.g.dart b/lib/library/database.g.dart index 9b759c3..ad986ad 100644 --- a/lib/library/database.g.dart +++ b/lib/library/database.g.dart @@ -113,6 +113,18 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> { ), defaultValue: const Constant(false), ); + static const VerificationMeta _playCountMeta = const VerificationMeta( + 'playCount', + ); + @override + late final GeneratedColumn playCount = GeneratedColumn( + 'play_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); @override List get $columns => [ id, @@ -125,6 +137,7 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> { dateAddedMs, updatedAtMs, deleted, + playCount, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -211,6 +224,12 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> { deleted.isAcceptableOrUnknown(data['deleted']!, _deletedMeta), ); } + if (data.containsKey('play_count')) { + context.handle( + _playCountMeta, + playCount.isAcceptableOrUnknown(data['play_count']!, _playCountMeta), + ); + } return context; } @@ -260,6 +279,10 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> { DriftSqlType.bool, data['${effectivePrefix}deleted'], )!, + playCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}play_count'], + )!, ); } @@ -280,6 +303,10 @@ class Song extends DataClass implements Insertable { final int dateAddedMs; final int updatedAtMs; final bool deleted; + + /// Wie oft der Song vollständig gestartet wurde — Grundlage für die + /// Sortierung "Wie oft abgespielt". + final int playCount; const Song({ required this.id, required this.path, @@ -291,6 +318,7 @@ class Song extends DataClass implements Insertable { required this.dateAddedMs, required this.updatedAtMs, required this.deleted, + required this.playCount, }); @override Map toColumns(bool nullToAbsent) { @@ -313,6 +341,7 @@ class Song extends DataClass implements Insertable { map['date_added_ms'] = Variable(dateAddedMs); map['updated_at_ms'] = Variable(updatedAtMs); map['deleted'] = Variable(deleted); + map['play_count'] = Variable(playCount); return map; } @@ -336,6 +365,7 @@ class Song extends DataClass implements Insertable { dateAddedMs: Value(dateAddedMs), updatedAtMs: Value(updatedAtMs), deleted: Value(deleted), + playCount: Value(playCount), ); } @@ -355,6 +385,7 @@ class Song extends DataClass implements Insertable { dateAddedMs: serializer.fromJson(json['dateAddedMs']), updatedAtMs: serializer.fromJson(json['updatedAtMs']), deleted: serializer.fromJson(json['deleted']), + playCount: serializer.fromJson(json['playCount']), ); } @override @@ -371,6 +402,7 @@ class Song extends DataClass implements Insertable { 'dateAddedMs': serializer.toJson(dateAddedMs), 'updatedAtMs': serializer.toJson(updatedAtMs), 'deleted': serializer.toJson(deleted), + 'playCount': serializer.toJson(playCount), }; } @@ -385,6 +417,7 @@ class Song extends DataClass implements Insertable { int? dateAddedMs, int? updatedAtMs, bool? deleted, + int? playCount, }) => Song( id: id ?? this.id, path: path ?? this.path, @@ -396,6 +429,7 @@ class Song extends DataClass implements Insertable { dateAddedMs: dateAddedMs ?? this.dateAddedMs, updatedAtMs: updatedAtMs ?? this.updatedAtMs, deleted: deleted ?? this.deleted, + playCount: playCount ?? this.playCount, ); Song copyWithCompanion(SongsCompanion data) { return Song( @@ -415,6 +449,7 @@ class Song extends DataClass implements Insertable { ? data.updatedAtMs.value : this.updatedAtMs, deleted: data.deleted.present ? data.deleted.value : this.deleted, + playCount: data.playCount.present ? data.playCount.value : this.playCount, ); } @@ -430,7 +465,8 @@ class Song extends DataClass implements Insertable { ..write('coverPath: $coverPath, ') ..write('dateAddedMs: $dateAddedMs, ') ..write('updatedAtMs: $updatedAtMs, ') - ..write('deleted: $deleted') + ..write('deleted: $deleted, ') + ..write('playCount: $playCount') ..write(')')) .toString(); } @@ -447,6 +483,7 @@ class Song extends DataClass implements Insertable { dateAddedMs, updatedAtMs, deleted, + playCount, ); @override bool operator ==(Object other) => @@ -461,7 +498,8 @@ class Song extends DataClass implements Insertable { other.coverPath == this.coverPath && other.dateAddedMs == this.dateAddedMs && other.updatedAtMs == this.updatedAtMs && - other.deleted == this.deleted); + other.deleted == this.deleted && + other.playCount == this.playCount); } class SongsCompanion extends UpdateCompanion { @@ -475,6 +513,7 @@ class SongsCompanion extends UpdateCompanion { final Value dateAddedMs; final Value updatedAtMs; final Value deleted; + final Value playCount; final Value rowid; const SongsCompanion({ this.id = const Value.absent(), @@ -487,6 +526,7 @@ class SongsCompanion extends UpdateCompanion { this.dateAddedMs = const Value.absent(), this.updatedAtMs = const Value.absent(), this.deleted = const Value.absent(), + this.playCount = const Value.absent(), this.rowid = const Value.absent(), }); SongsCompanion.insert({ @@ -500,6 +540,7 @@ class SongsCompanion extends UpdateCompanion { required int dateAddedMs, required int updatedAtMs, this.deleted = const Value.absent(), + this.playCount = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), path = Value(path), @@ -517,6 +558,7 @@ class SongsCompanion extends UpdateCompanion { Expression? dateAddedMs, Expression? updatedAtMs, Expression? deleted, + Expression? playCount, Expression? rowid, }) { return RawValuesInsertable({ @@ -530,6 +572,7 @@ class SongsCompanion extends UpdateCompanion { if (dateAddedMs != null) 'date_added_ms': dateAddedMs, if (updatedAtMs != null) 'updated_at_ms': updatedAtMs, if (deleted != null) 'deleted': deleted, + if (playCount != null) 'play_count': playCount, if (rowid != null) 'rowid': rowid, }); } @@ -545,6 +588,7 @@ class SongsCompanion extends UpdateCompanion { Value? dateAddedMs, Value? updatedAtMs, Value? deleted, + Value? playCount, Value? rowid, }) { return SongsCompanion( @@ -558,6 +602,7 @@ class SongsCompanion extends UpdateCompanion { dateAddedMs: dateAddedMs ?? this.dateAddedMs, updatedAtMs: updatedAtMs ?? this.updatedAtMs, deleted: deleted ?? this.deleted, + playCount: playCount ?? this.playCount, rowid: rowid ?? this.rowid, ); } @@ -595,6 +640,9 @@ class SongsCompanion extends UpdateCompanion { if (deleted.present) { map['deleted'] = Variable(deleted.value); } + if (playCount.present) { + map['play_count'] = Variable(playCount.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -614,6 +662,7 @@ class SongsCompanion extends UpdateCompanion { ..write('dateAddedMs: $dateAddedMs, ') ..write('updatedAtMs: $updatedAtMs, ') ..write('deleted: $deleted, ') + ..write('playCount: $playCount, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -2223,6 +2272,7 @@ typedef $$SongsTableCreateCompanionBuilder = required int dateAddedMs, required int updatedAtMs, Value deleted, + Value playCount, Value rowid, }); typedef $$SongsTableUpdateCompanionBuilder = @@ -2237,6 +2287,7 @@ typedef $$SongsTableUpdateCompanionBuilder = Value dateAddedMs, Value updatedAtMs, Value deleted, + Value playCount, Value rowid, }); @@ -2359,6 +2410,11 @@ class $$SongsTableFilterComposer extends Composer<_$MeloDb, $SongsTable> { builder: (column) => ColumnFilters(column), ); + ColumnFilters get playCount => $composableBuilder( + column: $table.playCount, + builder: (column) => ColumnFilters(column), + ); + Expression playlistSongsRefs( Expression Function($$PlaylistSongsTableFilterComposer f) f, ) { @@ -2492,6 +2548,11 @@ class $$SongsTableOrderingComposer extends Composer<_$MeloDb, $SongsTable> { column: $table.deleted, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get playCount => $composableBuilder( + column: $table.playCount, + builder: (column) => ColumnOrderings(column), + ); } class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> { @@ -2538,6 +2599,9 @@ class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> { GeneratedColumn get deleted => $composableBuilder(column: $table.deleted, builder: (column) => column); + GeneratedColumn get playCount => + $composableBuilder(column: $table.playCount, builder: (column) => column); + Expression playlistSongsRefs( Expression Function($$PlaylistSongsTableAnnotationComposer a) f, ) { @@ -2656,6 +2720,7 @@ class $$SongsTableTableManager Value dateAddedMs = const Value.absent(), Value updatedAtMs = const Value.absent(), Value deleted = const Value.absent(), + Value playCount = const Value.absent(), Value rowid = const Value.absent(), }) => SongsCompanion( id: id, @@ -2668,6 +2733,7 @@ class $$SongsTableTableManager dateAddedMs: dateAddedMs, updatedAtMs: updatedAtMs, deleted: deleted, + playCount: playCount, rowid: rowid, ), createCompanionCallback: @@ -2682,6 +2748,7 @@ class $$SongsTableTableManager required int dateAddedMs, required int updatedAtMs, Value deleted = const Value.absent(), + Value playCount = const Value.absent(), Value rowid = const Value.absent(), }) => SongsCompanion.insert( id: id, @@ -2694,6 +2761,7 @@ class $$SongsTableTableManager dateAddedMs: dateAddedMs, updatedAtMs: updatedAtMs, deleted: deleted, + playCount: playCount, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/library/favorites_screen.dart b/lib/library/favorites_screen.dart new file mode 100644 index 0000000..3d80078 --- /dev/null +++ b/lib/library/favorites_screen.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../shared/sort_store.dart'; +import '../shared/sortable_song_list.dart'; +import 'database.dart'; + +/// Favoriten-Tab: Shuffle-Wiedergabe und dieselbe Sortierung wie "Meine Musik", +/// nur auf den favorisierten Songs. +class FavoritesScreen extends StatelessWidget { + const FavoritesScreen({super.key}); + + @override + Widget build(BuildContext context) { + final db = context.read(); + return SafeArea( + bottom: false, + child: Column( + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(20, 16, 20, 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text('Favoriten', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)), + ), + ), + Expanded( + child: StreamBuilder>( + stream: db.watchFavorites(), + builder: (context, snapshot) { + final songs = snapshot.data ?? const []; + return SortableSongList( + songs: songs, + storeKey: SortStore.favoriten, + empty: const _Empty(), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _Empty extends StatelessWidget { + const _Empty(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.favorite_border, size: 64, color: Colors.white24), + SizedBox(height: 12), + Text('Noch keine Favoriten', style: TextStyle(color: Colors.white54)), + SizedBox(height: 6), + Text('Tippe in einer Liederliste auf das Herz.', + style: TextStyle(color: Colors.white30, fontSize: 12)), + ], + ), + ); + } +} diff --git a/lib/library/my_music_screen.dart b/lib/library/my_music_screen.dart new file mode 100644 index 0000000..084b0b6 --- /dev/null +++ b/lib/library/my_music_screen.dart @@ -0,0 +1,359 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../playlists/playlists_screen.dart'; +import '../settings/settings_screen.dart'; +import '../shared/cover.dart'; +import '../shared/sort_store.dart'; +import '../shared/sortable_song_list.dart'; +import '../shared/theme.dart'; +import 'album_list.dart'; +import 'artist_list.dart'; +import 'database.dart'; +import 'library_service.dart'; +import 'permissions.dart'; +import 'song_list.dart'; + +/// Startbildschirm: Kopfzeile mit Einstellungen/Suche/Musikerkennung, +/// Schnellzugriffe, darunter Shuffle + sortierbare Liederliste. +/// Bewusst ohne AppBar — die Kopfzeile ist Teil des Inhalts. +class MyMusicScreen extends StatelessWidget { + const MyMusicScreen({super.key, this.onSearchTap, this.onFavoritesTap}); + + /// Wechselt zum Suche-Tab; die Suche ist ein eigener Tab, kein eigener Screen. + final VoidCallback? onSearchTap; + + /// Wechselt zum Favoriten-Tab. + final VoidCallback? onFavoritesTap; + + @override + Widget build(BuildContext context) { + final db = context.read(); + final lib = context.watch(); + return SafeArea( + bottom: false, + child: Column( + children: [ + _Header(onSearchTap: onSearchTap), + if (lib.permissionDenied) + MaterialBanner( + backgroundColor: Colors.red.shade900, + content: const Text('Zugriff auf Musik verweigert. ' + 'Bitte in den Einstellungen erlauben.'), + actions: [ + TextButton( + onPressed: openMusicPermissionSettings, + child: const Text('Einstellungen'), + ), + ], + ), + if (lib.scanError != null) + MaterialBanner( + backgroundColor: Colors.red.shade900, + content: Text(lib.scanError!), + actions: [ + TextButton( + onPressed: lib.dismissScanError, + child: const Text('Verwerfen'), + ), + ], + ), + if (lib.scanning) + Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + const LinearProgressIndicator(), + const SizedBox(height: 6), + Text( + 'Scanne … ${lib.scanDone}' + '${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}', + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + ), + ), + _QuickAccessRow(onFavoritesTap: onFavoritesTap), + Expanded( + child: StreamBuilder>( + stream: db.watchSongs(), + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text('Fehler: ${snapshot.error}', + style: const TextStyle(color: Colors.white54)), + ); + } + final songs = snapshot.data ?? const []; + return SortableSongList( + songs: songs, + storeKey: SortStore.meineMusik, + empty: lib.scanning ? const SizedBox.shrink() : const _Empty(), + ); + }, + ), + ), + ], + ), + ); + } +} + +/// Einstellungen · Suchfeld · Musikerkennung. +class _Header extends StatelessWidget { + const _Header({this.onSearchTap}); + final VoidCallback? onSearchTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + child: Row( + children: [ + IconButton( + tooltip: 'Einstellungen', + icon: const Icon(Icons.tune), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SettingsScreen()), + ), + ), + Expanded( + child: InkWell( + onTap: onSearchTap, + borderRadius: BorderRadius.circular(24), + child: Container( + height: 44, + decoration: BoxDecoration( + color: MeloTheme.surfaceHigh, + borderRadius: BorderRadius.circular(24), + ), + child: const Row( + children: [ + SizedBox(width: 14), + Icon(Icons.search, color: Colors.white54, size: 20), + SizedBox(width: 10), + Text( + 'Titel, Künstler und Alben suchen', + style: TextStyle(color: Colors.white54, fontSize: 14), + ), + ], + ), + ), + ), + ), + IconButton( + tooltip: 'Musik erkennen', + icon: const Icon(Icons.help_outline), + onPressed: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Musikerkennung kommt später')), + ), + ), + ], + ), + ); + } +} + +/// Waagerecht scrollende Schnellzugriffe (Favoriten, Wiedergabelisten, …). +class _QuickAccessRow extends StatelessWidget { + const _QuickAccessRow({this.onFavoritesTap}); + final VoidCallback? onFavoritesTap; + + @override + Widget build(BuildContext context) { + final db = context.read(); + return SizedBox( + height: 104, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + children: [ + StreamBuilder>( + stream: db.watchFavorites(), + builder: (context, snapshot) { + final favorites = snapshot.data ?? const []; + return _QuickCard( + icon: Icons.favorite, + label: 'Favoriten', + subtitle: '${favorites.length} Songs', + coverPath: favorites.isEmpty ? null : favorites.first.coverPath, + onTap: onFavoritesTap, + ); + }, + ), + StreamBuilder>( + stream: db.watchPlaylists(), + builder: (context, snapshot) { + final playlists = snapshot.data ?? const []; + return _QuickCard( + icon: Icons.queue_music, + label: 'Wiedergabelisten', + subtitle: '${playlists.length} Listen', + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const PlaylistsScreen()), + ), + ); + }, + ), + StreamBuilder>( + stream: db.watchRecent(limit: 50), + builder: (context, snapshot) { + final recent = snapshot.data ?? const []; + return _QuickCard( + icon: Icons.schedule, + label: 'Zuletzt', + subtitle: 'Neu hinzugefügt', + coverPath: recent.isEmpty ? null : recent.first.coverPath, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const RecentlyAddedScreen()), + ), + ); + }, + ), + _QuickCard( + icon: Icons.person, + label: 'Künstler', + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ArtistsScreen()), + ), + ), + _QuickCard( + icon: Icons.album, + label: 'Alben', + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AlbumsScreen()), + ), + ), + ], + ), + ); + } +} + +class _QuickCard extends StatelessWidget { + const _QuickCard({ + required this.icon, + required this.label, + this.subtitle, + this.coverPath, + this.onTap, + }); + + final IconData icon; + final String label; + final String? subtitle; + final String? coverPath; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: SizedBox( + width: 150, + child: Material( + color: MeloTheme.surfaceHigh, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Stack( + fit: StackFit.expand, + children: [ + if (coverPath != null) + Opacity( + opacity: 0.35, + child: CoverImage( + artUri: Uri.file(coverPath!), + size: 150, + radius: 0, + ), + ), + Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Icon(icon, size: 22, color: Colors.white), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w600, fontSize: 14)), + if (subtitle != null) + Text(subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white54, fontSize: 11)), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Leerer Zustand mit direktem Weg zum Ordner-Scan. +class _Empty extends StatelessWidget { + const _Empty(); + + @override + Widget build(BuildContext context) { + final lib = context.read(); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.library_music, size: 64, color: Colors.white24), + const SizedBox(height: 12), + const Text('Noch keine Musik', + style: TextStyle(color: Colors.white54)), + const SizedBox(height: 16), + FilledButton.icon( + icon: const Icon(Icons.create_new_folder_outlined), + label: Text(addMusicLabel), + onPressed: lib.pickFolderAndScan, + ), + ], + ), + ); + } +} + +/// Vollbild-Liste der zuletzt hinzugefügten Songs (Schnellzugriff "Zuletzt"). +class RecentlyAddedScreen extends StatelessWidget { + const RecentlyAddedScreen({super.key}); + + @override + Widget build(BuildContext context) { + final db = context.read(); + return Scaffold( + appBar: AppBar(title: const Text('Zuletzt hinzugefügt')), + body: StreamBuilder>( + stream: db.watchRecent(limit: 100), + builder: (context, snapshot) { + final songs = snapshot.data ?? const []; + if (songs.isEmpty) { + return const Center( + child: Text('Noch nichts hinzugefügt', + style: TextStyle(color: Colors.white54)), + ); + } + return SongList(songs); + }, + ), + ); + } +} diff --git a/lib/library/song_sort.dart b/lib/library/song_sort.dart new file mode 100644 index 0000000..0963177 --- /dev/null +++ b/lib/library/song_sort.dart @@ -0,0 +1,61 @@ +import 'database.dart'; + +/// Sortier-Kriterien der Liederlisten (Meine Musik + Favoriten). +enum SortMode { dateAdded, name, playCount } + +extension SortModeLabel on SortMode { + String get label => switch (this) { + SortMode.dateAdded => 'Zuletzt hinzugefügt', + SortMode.name => 'Name (A–Z–#)', + SortMode.playCount => 'Wie oft abgespielt', + }; +} + +/// Richtung, in der ein Modus üblicherweise gelesen wird: neueste bzw. +/// meistgespielte Songs zuerst, Namen dagegen von A nach Z. +bool defaultAscending(SortMode mode) => mode == SortMode.name; + +/// Sortiert eine Kopie von [songs]. Bei gleichem Sortierwert entscheidet der +/// Titel, damit die Reihenfolge zwischen Aufrufen stabil bleibt. +List sortSongs(List songs, SortMode mode, {required bool ascending}) { + final sorted = [...songs]; + sorted.sort((a, b) { + final cmp = switch (mode) { + SortMode.dateAdded => a.dateAddedMs.compareTo(b.dateAddedMs), + SortMode.playCount => a.playCount.compareTo(b.playCount), + SortMode.name => _compareNames(a.title, b.title), + }; + if (cmp != 0) return ascending ? cmp : -cmp; + // Tiebreak immer aufsteigend nach Titel — sonst wäre die Reihenfolge + // gleichwertiger Songs beim Umschalten der Richtung willkürlich. + return _compareNames(a.title, b.title); + }); + return sorted; +} + +/// A–Z zuerst, alles was nicht mit einem Buchstaben beginnt (Ziffern, +/// Sonderzeichen) landet unter "#" am Ende. +int _compareNames(String a, String b) { + final keyA = _sortKey(a); + final keyB = _sortKey(b); + final letterA = _startsWithLetter(keyA); + final letterB = _startsWithLetter(keyB); + if (letterA != letterB) return letterA ? -1 : 1; + return keyA.compareTo(keyB); +} + +final _letter = RegExp(r'^\p{L}', unicode: true); + +bool _startsWithLetter(String s) => _letter.hasMatch(s); + +/// Kleinschreibung + Umlaute auf ihren Grundbuchstaben, damit "Ärger" +/// zwischen "Anfang" und "Berg" steht und nicht hinter "Zug". +String _sortKey(String title) { + return title + .trim() + .toLowerCase() + .replaceAll('ä', 'a') + .replaceAll('ö', 'o') + .replaceAll('ü', 'u') + .replaceAll('ß', 'ss'); +} diff --git a/lib/main.dart b/lib/main.dart index 446dd80..0116335 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,18 +2,17 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'downloads/downloads_screen.dart'; import 'library/database.dart'; -import 'library/library_screen.dart'; +import 'library/favorites_screen.dart'; import 'library/library_service.dart'; +import 'library/my_music_screen.dart'; import 'library/playlist_service.dart'; import 'library/search_screen.dart'; import 'player/audio_handler.dart'; import 'player/mini_player.dart'; -import 'player/now_playing_screen.dart'; -import 'playlists/playlists_screen.dart'; import 'services/logger_service.dart'; import 'services/offline_mode.dart'; -import 'settings/settings_screen.dart'; import 'shared/theme.dart'; late final MeloAudioHandler _handler; @@ -75,35 +74,37 @@ class HomeShell extends StatefulWidget { class _HomeShellState extends State { int _index = 0; - static const _tabs = [ - NowPlayingScreen(), - LibraryScreen(), - PlaylistsScreen(), - SearchScreen(), - SettingsScreen(), - ]; + void _goTo(int index) => setState(() => _index = index); @override Widget build(BuildContext context) { + final tabs = [ + MyMusicScreen( + onSearchTap: () => _goTo(1), + onFavoritesTap: () => _goTo(2), + ), + const SearchScreen(), + const FavoritesScreen(), + const DownloadsScreen(), + ]; return Scaffold( body: Column( children: [ - Expanded(child: IndexedStack(index: _index, children: _tabs)), - if (_index != 0) const MiniPlayer(), + Expanded(child: IndexedStack(index: _index, children: tabs)), + const MiniPlayer(), ], ), bottomNavigationBar: BottomNavigationBar( currentIndex: _index, - onTap: (i) => setState(() => _index = i), + onTap: _goTo, items: const [ BottomNavigationBarItem( - icon: Icon(Icons.play_circle_outline), label: 'Player'), - BottomNavigationBarItem( - icon: Icon(Icons.library_music), label: 'Bibliothek'), - BottomNavigationBarItem( - icon: Icon(Icons.playlist_play), label: 'Playlisten'), + icon: Icon(Icons.headphones), label: 'Meine Musik'), BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suche'), - BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Settings'), + BottomNavigationBarItem( + icon: Icon(Icons.favorite_border), label: 'Favoriten'), + BottomNavigationBarItem( + icon: Icon(Icons.download_outlined), label: 'Download'), ], ), ); diff --git a/lib/player/audio_handler.dart b/lib/player/audio_handler.dart index 608af19..f2fec7d 100644 --- a/lib/player/audio_handler.dart +++ b/lib/player/audio_handler.dart @@ -20,6 +20,12 @@ bool shouldResumeAt(int lastPositionMs, Duration? trackDuration) { return true; } +/// Entscheidet, ob für den Titel an [newIndex] eine Wiedergabe gezählt wird. +/// Gezählt wird jeder Titelwechsel; eine Wiederholung desselben Titels +/// (LoopMode.one) zählt bewusst nicht erneut. +bool shouldCountPlay(int? newIndex, int? lastCountedIndex) => + newIndex != null && newIndex != lastCountedIndex; + /// Kern der Wiedergabe: kapselt just_audio hinter audio_service, /// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren. class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { @@ -29,6 +35,7 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { late final Timer _positionRecordTimer; late final CacheManager _cache; final NavidromeService _nav = NavidromeService(); + int? _lastCountedIndex; MeloAudioHandler({required this.db}) { _cache = CacheManager(); @@ -56,6 +63,11 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final q = queue.value; if (index != null && index < q.length) { mediaItem.add(q[index].copyWith(duration: _player.duration)); + if (shouldCountPlay(index, _lastCountedIndex)) { + _lastCountedIndex = index; + final songId = q[index].extras?['songId'] as String?; + if (songId != null) db.incrementPlayCount(songId); + } } }); @@ -72,6 +84,9 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { /// Auto-Caching: Streame von Server + speichere lokal gleichzeitig. Future loadPlaylist(List items, {int startIndex = 0}) async { queue.add(items); + // Neue Warteschlange: der erste Titel soll wieder zählen, auch wenn er + // denselben Index wie der zuletzt gezählte hat. + _lastCountedIndex = null; await _nav.ladeGespeicherteZugangsdaten(); final sources = []; diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index 1e56f35..b7d84c3 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../library/database.dart'; +import '../library/library_service.dart'; import '../library/permissions.dart'; import '../library/playlist_service.dart'; import '../services/cache_manager.dart'; @@ -58,6 +59,30 @@ class _SettingsScreenState extends State { title: Text('${formatTotalDuration(songs)} Gesamtspieldauer'), ), + Consumer( + builder: (context, lib, _) => Column( + children: [ + ListTile( + leading: const Icon(Icons.create_new_folder_outlined), + title: Text(addMusicLabel), + subtitle: const Text( + 'Musikordner auswählen und einlesen'), + enabled: !lib.scanning, + onTap: lib.pickFolderAndScan, + ), + ListTile( + leading: const Icon(Icons.refresh), + title: const Text('Erneut scannen'), + subtitle: lib.scanning + ? Text('Scanne … ${lib.scanDone}' + '${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}') + : const Text('Bekannte Ordner neu einlesen'), + enabled: !lib.scanning, + onTap: lib.rescan, + ), + ], + ), + ), ], ); }, diff --git a/lib/shared/sort_store.dart b/lib/shared/sort_store.dart new file mode 100644 index 0000000..01240d9 --- /dev/null +++ b/lib/shared/sort_store.dart @@ -0,0 +1,38 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../library/song_sort.dart'; + +/// Sortier-Wahl einer Liederliste: Kriterium + Richtung. +class SortSetting { + const SortSetting(this.mode, {required this.ascending}); + final SortMode mode; + final bool ascending; +} + +/// Merkt sich die Sortier-Wahl pro Liste ([key] z. B. 'meine_musik'), +/// damit sie einen App-Neustart überlebt. +class SortStore { + static const meineMusik = 'meine_musik'; + static const favoriten = 'favoriten'; + + static const _standard = SortSetting(SortMode.dateAdded, ascending: false); + + static Future load(String key) async { + final prefs = await SharedPreferences.getInstance(); + final index = prefs.getInt('sort_${key}_mode'); + if (index == null || index < 0 || index >= SortMode.values.length) { + return _standard; + } + final mode = SortMode.values[index]; + return SortSetting( + mode, + ascending: prefs.getBool('sort_${key}_asc') ?? defaultAscending(mode), + ); + } + + static Future save(String key, SortSetting setting) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('sort_${key}_mode', setting.mode.index); + await prefs.setBool('sort_${key}_asc', setting.ascending); + } +} diff --git a/lib/shared/sortable_song_list.dart b/lib/shared/sortable_song_list.dart new file mode 100644 index 0000000..d656d34 --- /dev/null +++ b/lib/shared/sortable_song_list.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../library/database.dart'; +import '../library/song_list.dart'; +import '../library/song_media.dart'; +import '../library/song_sort.dart'; +import '../player/audio_handler.dart'; +import 'sort_store.dart'; +import 'theme.dart'; + +/// Liederliste mit Shuffle-Wiedergabe und Sortier-Menü darüber — identisch in +/// "Meine Musik" und "Favoriten". [storeKey] trennt die gemerkte Sortier-Wahl +/// der beiden Listen ([SortStore.meineMusik] / [SortStore.favoriten]). +class SortableSongList extends StatefulWidget { + const SortableSongList({ + super.key, + required this.songs, + required this.storeKey, + this.empty, + }); + + final List songs; + final String storeKey; + + /// Wird statt der Liste gezeigt, wenn [songs] leer ist. + final Widget? empty; + + @override + State createState() => _SortableSongListState(); +} + +class _SortableSongListState extends State { + SortSetting _setting = const SortSetting( + SortMode.dateAdded, + ascending: false, + ); + + @override + void initState() { + super.initState(); + SortStore.load(widget.storeKey).then((s) { + if (mounted) setState(() => _setting = s); + }); + } + + Future _shuffle() async { + if (widget.songs.isEmpty) return; + final handler = context.read(); + final messenger = ScaffoldMessenger.of(context); + final shuffled = [...widget.songs]..shuffle(); + try { + await playSongs(handler, shuffled, 0); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')), + ); + } + } + + Future _chooseSort() async { + final chosen = await showModalBottomSheet( + context: context, + backgroundColor: MeloTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => _SortSheet(current: _setting), + ); + if (chosen == null) return; + setState(() => _setting = chosen); + await SortStore.save(widget.storeKey, chosen); + } + + @override + Widget build(BuildContext context) { + final sorted = sortSongs( + widget.songs, + _setting.mode, + ascending: _setting.ascending, + ); + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 8, 4), + child: Row( + children: [ + Expanded( + child: InkWell( + onTap: widget.songs.isEmpty ? null : _shuffle, + borderRadius: BorderRadius.circular(24), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow, + color: Colors.black, + size: 24, + ), + ), + const SizedBox(width: 12), + const Text( + 'Shuffle-Wiedergabe', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + IconButton( + tooltip: 'Sortieren: ${_setting.mode.label}', + icon: const Icon(Icons.swap_vert), + onPressed: _chooseSort, + ), + ], + ), + ), + Expanded( + child: sorted.isEmpty + ? (widget.empty ?? const SizedBox.shrink()) + : SongList(sorted), + ), + ], + ); + } +} + +/// Bottom-Sheet zur Wahl von Sortier-Kriterium und Richtung. +class _SortSheet extends StatefulWidget { + const _SortSheet({required this.current}); + final SortSetting current; + + @override + State<_SortSheet> createState() => _SortSheetState(); +} + +class _SortSheetState extends State<_SortSheet> { + late SortMode _mode = widget.current.mode; + late bool _ascending = widget.current.ascending; + + @override + Widget build(BuildContext context) { + // Scrollbar, damit das Sheet auch auf niedrigen Bildschirmen und im + // Querformat vollständig erreichbar bleibt. + return SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(20, 20, 20, 8), + child: Text( + 'Sortieren', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + ), + for (final mode in SortMode.values) + ListTile( + title: Text( + mode.label, + style: TextStyle( + color: mode == _mode ? MeloTheme.red : Colors.white, + fontWeight: mode == _mode + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + trailing: mode == _mode + ? const Icon(Icons.check, color: MeloTheme.red) + : null, + onTap: () => setState(() { + _mode = mode; + // Jedes Kriterium hat eine natürliche Leserichtung; die Wahl + // eines neuen Kriteriums setzt sie zurück. + _ascending = defaultAscending(mode); + }), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), + child: Row( + children: [ + Expanded( + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: true, + icon: Icon(Icons.arrow_upward, size: 16), + label: Text('Aufsteigend'), + ), + ButtonSegment( + value: false, + icon: Icon(Icons.arrow_downward, size: 16), + label: Text('Absteigend'), + ), + ], + selected: {_ascending}, + onSelectionChanged: (s) => + setState(() => _ascending = s.first), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 16), + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => Navigator.pop( + context, + SortSetting(_mode, ascending: _ascending), + ), + child: const Text('Übernehmen'), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/shared/theme.dart b/lib/shared/theme.dart index 0505cd4..9c98b41 100644 --- a/lib/shared/theme.dart +++ b/lib/shared/theme.dart @@ -2,9 +2,12 @@ import 'package:flutter/material.dart'; /// Melo-Theme: Schwarz + Rot. class MeloTheme { - static const Color red = Color(0xFFE50914); - static const Color black = Color(0xFF0A0A0A); - static const Color surface = Color(0xFF161616); + static const Color red = Color(0xFFC0392B); + static const Color black = Color(0xFF0B0B10); + static const Color surface = Color(0xFF15151C); + + /// Etwas hellere Fläche für Karten und Chips auf [surface]. + static const Color surfaceHigh = Color(0xFF1F1F29); static ThemeData get dark { final scheme = ColorScheme.fromSeed( diff --git a/test/library/library_screen_test.dart b/test/library/library_screen_test.dart deleted file mode 100644 index a3b3aaa..0000000 --- a/test/library/library_screen_test.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:drift/native.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:provider/provider.dart'; -import 'package:melo/library/database.dart'; -import 'package:melo/library/library_screen.dart'; -import 'package:melo/library/library_service.dart'; -import 'package:melo/library/playlist_service.dart'; -import 'package:melo/player/audio_handler.dart'; - -Widget _wrap( - MeloDb db, - LibraryService lib, - PlaylistService playlists, - MeloAudioHandler handler, - Widget child, -) { - return MultiProvider( - providers: [ - Provider.value(value: db), - ChangeNotifierProvider.value(value: lib), - ChangeNotifierProvider.value(value: playlists), - Provider.value(value: handler), - ], - child: MaterialApp(home: child), - ); -} - -Future _insertSong( - MeloDb db, { - required String id, - required String title, - String? artist, - String? album, -}) { - return db.into(db.songs).insert(SongsCompanion.insert( - id: id, - path: '/$id.mp3', - title: title, - artist: Value(artist), - album: Value(album), - dateAddedMs: 0, - updatedAtMs: 0, - )); -} - -void main() { - testWidgets('zeigt die drei Bibliotheks-Tabs "Alle", "Künstler", "Alben"', - (tester) async { - final db = MeloDb(NativeDatabase.memory()); - final lib = LibraryService(db); - final playlists = PlaylistService(db); - final handler = MeloAudioHandler(db: db); - - await tester.pumpWidget( - _wrap(db, lib, playlists, handler, const LibraryScreen()), - ); - await tester.pumpAndSettle(); - - expect(find.text('Alle'), findsOneWidget); - expect(find.text('Künstler'), findsOneWidget); - expect(find.text('Alben'), findsOneWidget); - - handler.dispose(); - await db.close(); - }); - - testWidgets('Künstler-Tab zeigt je Künstler eine Zeile', (tester) async { - final db = MeloDb(NativeDatabase.memory()); - final lib = LibraryService(db); - final playlists = PlaylistService(db); - final handler = MeloAudioHandler(db: db); - await _insertSong(db, id: '1', title: 'Song A', artist: 'Alice'); - await _insertSong(db, id: '2', title: 'Song B', artist: 'Bob'); - - await tester.pumpWidget( - _wrap(db, lib, playlists, handler, const LibraryScreen()), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Künstler')); - await tester.pumpAndSettle(); - - expect(find.text('Alice'), findsOneWidget); - expect(find.text('Bob'), findsOneWidget); - expect(find.text('1 Songs'), findsNWidgets(2)); - - handler.dispose(); - await db.close(); - }); - - testWidgets('Alben-Tab zeigt je Album eine Zeile', (tester) async { - final db = MeloDb(NativeDatabase.memory()); - final lib = LibraryService(db); - final playlists = PlaylistService(db); - final handler = MeloAudioHandler(db: db); - await _insertSong(db, id: '1', title: 'Song A', artist: 'Alice', album: 'Best Of'); - - await tester.pumpWidget( - _wrap(db, lib, playlists, handler, const LibraryScreen()), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Alben')); - await tester.pumpAndSettle(); - - expect(find.text('Best Of'), findsOneWidget); - - handler.dispose(); - await db.close(); - }); -} diff --git a/test/library/my_music_screen_test.dart b/test/library/my_music_screen_test.dart new file mode 100644 index 0000000..114ee19 --- /dev/null +++ b/test/library/my_music_screen_test.dart @@ -0,0 +1,149 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/favorites_screen.dart'; +import 'package:melo/library/library_service.dart'; +import 'package:melo/library/my_music_screen.dart'; +import 'package:melo/library/playlist_service.dart'; +import 'package:melo/player/audio_handler.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Widget _wrap(MeloDb db, LibraryService lib, PlaylistService playlists, + MeloAudioHandler handler, Widget child) { + return MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: lib), + ChangeNotifierProvider.value(value: playlists), + Provider.value(value: handler), + ], + child: MaterialApp(home: Scaffold(body: child)), + ); +} + +Future _insert( + MeloDb db, { + required String id, + required String title, + int dateAddedMs = 0, + int playCount = 0, +}) { + return db.into(db.songs).insert(SongsCompanion.insert( + id: id, + path: '/$id.mp3', + title: title, + dateAddedMs: dateAddedMs, + updatedAtMs: 0, + playCount: Value(playCount), + )); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('Meine Musik zeigt Kopfzeile, Schnellzugriffe und Shuffle', + (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final lib = LibraryService(db); + final playlists = PlaylistService(db); + final handler = MeloAudioHandler(db: db); + await _insert(db, id: '1', title: 'Song A'); + + await tester.pumpWidget( + _wrap(db, lib, playlists, handler, const MyMusicScreen())); + await tester.pumpAndSettle(); + + expect(find.text('Titel, Künstler und Alben suchen'), findsOneWidget); + expect(find.text('Favoriten'), findsOneWidget); + expect(find.text('Wiedergabelisten'), findsOneWidget); + expect(find.text('Shuffle-Wiedergabe'), findsOneWidget); + expect(find.text('Song A'), findsOneWidget); + + handler.dispose(); + await db.close(); + }); + + testWidgets('Sortier-Menü bietet alle drei Kriterien und sortiert die Liste', + (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final lib = LibraryService(db); + final playlists = PlaylistService(db); + final handler = MeloAudioHandler(db: db); + await _insert(db, id: '1', title: 'Zenit', dateAddedMs: 300, playCount: 9); + await _insert(db, id: '2', title: 'Anfang', dateAddedMs: 100, playCount: 1); + + await tester.pumpWidget( + _wrap(db, lib, playlists, handler, const MyMusicScreen())); + await tester.pumpAndSettle(); + + // Standard: zuletzt hinzugefügt zuerst. + var titles = tester.widgetList(find.byType(Text)) + .map((t) => t.data) + .where((t) => t == 'Zenit' || t == 'Anfang') + .toList(); + expect(titles.first, 'Zenit'); + + await tester.tap(find.byIcon(Icons.swap_vert)); + await tester.pumpAndSettle(); + + expect(find.text('Zuletzt hinzugefügt'), findsOneWidget); + expect(find.text('Name (A–Z–#)'), findsOneWidget); + expect(find.text('Wie oft abgespielt'), findsOneWidget); + + await tester.tap(find.text('Name (A–Z–#)')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Übernehmen')); + await tester.pumpAndSettle(); + + titles = tester.widgetList(find.byType(Text)) + .map((t) => t.data) + .where((t) => t == 'Zenit' || t == 'Anfang') + .toList(); + expect(titles.first, 'Anfang'); + + handler.dispose(); + await db.close(); + }); + + testWidgets('Favoriten-Tab zeigt nur favorisierte Songs mit Shuffle', + (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final lib = LibraryService(db); + final playlists = PlaylistService(db); + final handler = MeloAudioHandler(db: db); + await _insert(db, id: '1', title: 'Geliebt'); + await _insert(db, id: '2', title: 'Egal'); + await db.setFavorite('1', true); + + await tester.pumpWidget( + _wrap(db, lib, playlists, handler, const FavoritesScreen())); + await tester.pumpAndSettle(); + + expect(find.text('Shuffle-Wiedergabe'), findsOneWidget); + expect(find.text('Geliebt'), findsOneWidget); + expect(find.text('Egal'), findsNothing); + + handler.dispose(); + await db.close(); + }); + + testWidgets('Favoriten-Tab zeigt einen Hinweis, wenn nichts favorisiert ist', + (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final lib = LibraryService(db); + final playlists = PlaylistService(db); + final handler = MeloAudioHandler(db: db); + + await tester.pumpWidget( + _wrap(db, lib, playlists, handler, const FavoritesScreen())); + await tester.pumpAndSettle(); + + expect(find.text('Noch keine Favoriten'), findsOneWidget); + + handler.dispose(); + await db.close(); + }); +} diff --git a/test/library/song_grouping_test.dart b/test/library/song_grouping_test.dart index 553553a..f80412c 100644 --- a/test/library/song_grouping_test.dart +++ b/test/library/song_grouping_test.dart @@ -17,6 +17,7 @@ Song _song({ dateAddedMs: 0, updatedAtMs: 0, deleted: false, + playCount: 0, ); } diff --git a/test/library/song_media_test.dart b/test/library/song_media_test.dart index 8908b94..0dabeef 100644 --- a/test/library/song_media_test.dart +++ b/test/library/song_media_test.dart @@ -15,6 +15,7 @@ void main() { dateAddedMs: 0, updatedAtMs: 0, deleted: false, + playCount: 0, ); final item = songToMediaItem(song); diff --git a/test/library/song_sort_test.dart b/test/library/song_sort_test.dart new file mode 100644 index 0000000..89bc13a --- /dev/null +++ b/test/library/song_sort_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/song_sort.dart'; + +Song _song( + String title, { + String? id, + int dateAddedMs = 0, + int playCount = 0, +}) { + return Song( + id: id ?? title, + path: '/$title.mp3', + title: title, + dateAddedMs: dateAddedMs, + updatedAtMs: 0, + deleted: false, + playCount: playCount, + ); +} + +void main() { + group('SortMode.name — A-Z, Ziffern/Zeichen unter # ans Ende', () { + test('sortiert Buchstaben alphabetisch, Ziffern und Zeichen danach', () { + final songs = [ + _song('9 Lives'), + _song('Zenit'), + _song('#Hashtag'), + _song('Anfang'), + ]; + + final sorted = sortSongs(songs, SortMode.name, ascending: true); + + expect(sorted.map((s) => s.title), ['Anfang', 'Zenit', '#Hashtag', '9 Lives']); + }); + + test('ignoriert Groß-/Kleinschreibung', () { + final songs = [_song('beta'), _song('Alpha'), _song('CHARLIE')]; + + final sorted = sortSongs(songs, SortMode.name, ascending: true); + + expect(sorted.map((s) => s.title), ['Alpha', 'beta', 'CHARLIE']); + }); + + test('Umlaute sortieren wie ihr Grundbuchstabe', () { + final songs = [_song('Zug'), _song('Ärger'), _song('Anfang'), _song('Öl')]; + + final sorted = sortSongs(songs, SortMode.name, ascending: true); + + expect(sorted.map((s) => s.title), ['Anfang', 'Ärger', 'Öl', 'Zug']); + }); + + test('absteigend dreht die Reihenfolge um', () { + final songs = [_song('Anfang'), _song('9 Lives'), _song('Zenit')]; + + final sorted = sortSongs(songs, SortMode.name, ascending: false); + + expect(sorted.map((s) => s.title), ['9 Lives', 'Zenit', 'Anfang']); + }); + }); + + group('SortMode.dateAdded', () { + test('absteigend = zuletzt hinzugefügt zuerst', () { + final songs = [ + _song('Alt', dateAddedMs: 100), + _song('Neu', dateAddedMs: 300), + _song('Mittel', dateAddedMs: 200), + ]; + + final sorted = sortSongs(songs, SortMode.dateAdded, ascending: false); + + expect(sorted.map((s) => s.title), ['Neu', 'Mittel', 'Alt']); + }); + + test('aufsteigend = ältester zuerst', () { + final songs = [ + _song('Neu', dateAddedMs: 300), + _song('Alt', dateAddedMs: 100), + ]; + + final sorted = sortSongs(songs, SortMode.dateAdded, ascending: true); + + expect(sorted.map((s) => s.title), ['Alt', 'Neu']); + }); + }); + + group('SortMode.playCount', () { + test('absteigend = meistgespielt zuerst', () { + final songs = [ + _song('Selten', playCount: 1), + _song('Oft', playCount: 42), + _song('Nie', playCount: 0), + ]; + + final sorted = sortSongs(songs, SortMode.playCount, ascending: false); + + expect(sorted.map((s) => s.title), ['Oft', 'Selten', 'Nie']); + }); + }); + + group('Stabilität', () { + test('gleiche Sortierwerte werden nach Titel entschieden', () { + final songs = [ + _song('Bravo', id: 'b', dateAddedMs: 5), + _song('Alpha', id: 'a', dateAddedMs: 5), + ]; + + final sorted = sortSongs(songs, SortMode.dateAdded, ascending: false); + + expect(sorted.map((s) => s.title), ['Alpha', 'Bravo']); + }); + + test('verändert die Eingabeliste nicht', () { + final songs = [_song('Zenit'), _song('Anfang')]; + + sortSongs(songs, SortMode.name, ascending: true); + + expect(songs.map((s) => s.title), ['Zenit', 'Anfang']); + }); + }); + + group('Standard-Richtung pro Modus', () { + test('Datum und Wiedergaben starten absteigend, Name aufsteigend', () { + expect(defaultAscending(SortMode.dateAdded), isFalse); + expect(defaultAscending(SortMode.playCount), isFalse); + expect(defaultAscending(SortMode.name), isTrue); + }); + }); + + group('Beschriftungen', () { + test('jeder Modus hat ein deutsches Label', () { + expect(SortMode.dateAdded.label, 'Zuletzt hinzugefügt'); + expect(SortMode.name.label, 'Name (A–Z–#)'); + expect(SortMode.playCount.label, 'Wie oft abgespielt'); + }); + }); +} diff --git a/test/player/play_count_test.dart b/test/player/play_count_test.dart new file mode 100644 index 0000000..d7f162a --- /dev/null +++ b/test/player/play_count_test.dart @@ -0,0 +1,60 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/player/audio_handler.dart'; + +void main() { + group('shouldCountPlay', () { + test('zählt einen neu gestarteten Titel', () { + expect(shouldCountPlay(0, null), isTrue); + expect(shouldCountPlay(3, 2), isTrue); + }); + + test('zählt denselben Titel nicht mehrfach', () { + expect(shouldCountPlay(2, 2), isFalse); + }); + + test('zählt nicht ohne aktuellen Titel', () { + expect(shouldCountPlay(null, 1), isFalse); + }); + }); + + group('MeloDb.incrementPlayCount', () { + late MeloDb db; + setUp(() => db = MeloDb(NativeDatabase.memory())); + tearDown(() => db.close()); + + test('erhöht den Zähler des Songs um eins', () async { + await db.upsertSongs([ + SongsCompanion.insert( + id: '1', + path: '/a.mp3', + title: 'Song A', + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + + await db.incrementPlayCount('1'); + await db.incrementPlayCount('1'); + + final song = (await db.watchSongs().first).single; + expect(song.playCount, 2); + }); + + test('lässt andere Songs unberührt', () async { + await db.upsertSongs([ + SongsCompanion.insert( + id: '1', path: '/a.mp3', title: 'A', dateAddedMs: 0, updatedAtMs: 0), + SongsCompanion.insert( + id: '2', path: '/b.mp3', title: 'B', dateAddedMs: 0, updatedAtMs: 0), + ]); + + await db.incrementPlayCount('1'); + + final songs = await db.watchSongs().first; + expect(songs.firstWhere((s) => s.id == '1').playCount, 1); + expect(songs.firstWhere((s) => s.id == '2').playCount, 0); + }); + }); +} diff --git a/test/settings/library_stats_test.dart b/test/settings/library_stats_test.dart index ebdbd61..7035fd7 100644 --- a/test/settings/library_stats_test.dart +++ b/test/settings/library_stats_test.dart @@ -11,6 +11,7 @@ Song _song({required String id, int? durationMs}) { dateAddedMs: 0, updatedAtMs: 0, deleted: false, + playCount: 0, ); } diff --git a/test/settings/settings_screen_test.dart b/test/settings/settings_screen_test.dart index a828819..cce085a 100644 --- a/test/settings/settings_screen_test.dart +++ b/test/settings/settings_screen_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:melo/library/database.dart'; +import 'package:melo/library/library_service.dart'; import 'package:melo/services/offline_mode.dart'; import 'package:melo/settings/settings_screen.dart'; @@ -12,6 +13,7 @@ Widget _wrap(MeloDb db, Widget child) { providers: [ Provider.value(value: db), ChangeNotifierProvider.value(value: OfflineMode()), + ChangeNotifierProvider.value(value: LibraryService(db)), ], child: MaterialApp(home: child), ); @@ -63,6 +65,11 @@ void main() { await tester.pumpWidget(_wrap(db, const SettingsScreen())); await tester.pumpAndSettle(); + // Die Sync-Einträge liegen weiter unten in der Liste und müssen erst + // in den sichtbaren Bereich gescrollt werden. + await tester.scrollUntilVisible(find.text('🌟 Favoriten-Sync'), 200); + await tester.pumpAndSettle(); + expect(find.text('🌟 Favoriten-Sync'), findsOneWidget); expect(find.text('📋 Playlisten-Sync'), findsOneWidget); diff --git a/test/shared/sort_store_test.dart b/test/shared/sort_store_test.dart new file mode 100644 index 0000000..69b682c --- /dev/null +++ b/test/shared/sort_store_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/song_sort.dart'; +import 'package:melo/shared/sort_store.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('ohne gespeicherte Wahl gilt "Zuletzt hinzugefügt", neueste zuerst', + () async { + final setting = await SortStore.load('meine_musik'); + + expect(setting.mode, SortMode.dateAdded); + expect(setting.ascending, isFalse); + }); + + test('gespeicherte Wahl wird zurückgelesen', () async { + await SortStore.save( + 'meine_musik', + const SortSetting(SortMode.name, ascending: true), + ); + + final setting = await SortStore.load('meine_musik'); + + expect(setting.mode, SortMode.name); + expect(setting.ascending, isTrue); + }); + + test('Listen speichern getrennt voneinander', () async { + await SortStore.save( + 'meine_musik', + const SortSetting(SortMode.playCount, ascending: false), + ); + await SortStore.save( + 'favoriten', + const SortSetting(SortMode.name, ascending: true), + ); + + expect((await SortStore.load('meine_musik')).mode, SortMode.playCount); + expect((await SortStore.load('favoriten')).mode, SortMode.name); + }); + + test('unbrauchbarer gespeicherter Wert fällt auf den Standard zurück', + () async { + SharedPreferences.setMockInitialValues({'sort_meine_musik_mode': 99}); + + final setting = await SortStore.load('meine_musik'); + + expect(setting.mode, SortMode.dateAdded); + }); +}