diff --git a/CHANGELOG.md b/CHANGELOG.md index adeec9f..8059b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🧭 Neue Tab-Struktur nach deinen Referenzbildern (2026-08-20) +- 🔀 **Untere Leiste wie auf deinem Bild:** Die Reihenfolge ist jetzt + **Meine Musik · Online · Suchen · Favoriten**. Der frühere Reiter + "Download" heißt jetzt **Online** (Musiknoten-Symbol) — dort kommt als + Nächstes der YouTube-Downloader dazu. +- 🗂️ **Unterreiter in "Meine Musik":** Über der Liste sitzen jetzt + **Songs · Künstler · Alben** als Pillen — genau wie in der Referenz. Der + aktive Reiter ist hell, die anderen sind nur Text. + Wichtig: Ein Unterreiter **wechselt nur den Inhalt** und öffnet keinen + neuen Bildschirm — Suchfeld und Schnellzugriffe bleiben stehen. +- 🧹 **Aufgeräumt:** Die Schnellzugriff-Kacheln "Künstler" und "Alben" sind + weg, weil sie jetzt Unterreiter sind. Übrig bleiben — wie in der Referenz — + **Favoriten · Wiedergabelisten · Zuletzt**. +- 🔧 **Details:** `MyMusicScreen` ist jetzt zustandsbehaftet (`_subTab`), neues + `_SubTabs`-Widget; die dadurch toten `ArtistsScreen`/`AlbumsScreen`-Hüllen + wurden entfernt (`ArtistListScreen`/`AlbumListScreen` bleiben und werden + direkt eingebettet). +- ✅ **Getestet:** 158 Tests grün (4 neue), `flutter analyze` ohne Befund. + ### 🎼 Songtext, Warteschlange, Sperrbildschirm-Cover und gleiche Lautstärke (2026-08-20) - 📝 **Automatischer Songtext:** Beim Scan wird der Songtext aus dem Tag der Datei mitgelesen und in der Wiedergabe sofort angezeigt — **ohne Server und diff --git a/android/.kotlin/sessions/kotlin-compiler-11005204419413496908.salive b/android/.kotlin/sessions/kotlin-compiler-11005204419413496908.salive new file mode 100644 index 0000000..e69de29 diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart index ffe0967..d8006b2 100644 --- a/lib/downloads/downloads_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -5,7 +5,7 @@ import 'package:provider/provider.dart'; import '../player/audio_handler.dart'; import '../services/navidrome_service.dart'; -/// Download-Tab: durchsucht den verbundenen Navidrome-Server. Abgespielte +/// Online-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 { @@ -21,7 +21,7 @@ class DownloadsScreen extends StatelessWidget { padding: EdgeInsets.fromLTRB(20, 16, 20, 4), child: Align( alignment: Alignment.centerLeft, - child: Text('Download', + child: Text('Online', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)), ), ), diff --git a/lib/library/album_list.dart b/lib/library/album_list.dart index 6139714..de82dca 100644 --- a/lib/library/album_list.dart +++ b/lib/library/album_list.dart @@ -67,16 +67,3 @@ 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 5d3be9b..acdd271 100644 --- a/lib/library/artist_list.dart +++ b/lib/library/artist_list.dart @@ -59,16 +59,3 @@ 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/my_music_screen.dart b/lib/library/my_music_screen.dart index 084b0b6..37595a2 100644 --- a/lib/library/my_music_screen.dart +++ b/lib/library/my_music_screen.dart @@ -15,9 +15,9 @@ 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 { +/// Schnellzugriffe, Unterreiter (Songs/Künstler/Alben) und darunter der +/// jeweilige Inhalt. Bewusst ohne AppBar — die Kopfzeile ist Teil des Inhalts. +class MyMusicScreen extends StatefulWidget { const MyMusicScreen({super.key, this.onSearchTap, this.onFavoritesTap}); /// Wechselt zum Suche-Tab; die Suche ist ein eigener Tab, kein eigener Screen. @@ -26,6 +26,14 @@ class MyMusicScreen extends StatelessWidget { /// Wechselt zum Favoriten-Tab. final VoidCallback? onFavoritesTap; + @override + State createState() => _MyMusicScreenState(); +} + +class _MyMusicScreenState extends State { + /// 0 = Songs, 1 = Künstler, 2 = Alben. + int _subTab = 0; + @override Widget build(BuildContext context) { final db = context.read(); @@ -34,7 +42,7 @@ class MyMusicScreen extends StatelessWidget { bottom: false, child: Column( children: [ - _Header(onSearchTap: onSearchTap), + _Header(onSearchTap: widget.onSearchTap), if (lib.permissionDenied) MaterialBanner( backgroundColor: Colors.red.shade900, @@ -73,25 +81,34 @@ class MyMusicScreen extends StatelessWidget { ], ), ), - _QuickAccessRow(onFavoritesTap: onFavoritesTap), + _QuickAccessRow(onFavoritesTap: widget.onFavoritesTap), + _SubTabs( + index: _subTab, + onChanged: (i) => setState(() => _subTab = i), + ), 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(), - ); - }, - ), + child: switch (_subTab) { + 1 => const ArtistListScreen(), + 2 => const AlbumListScreen(), + _ => 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(), + ); + }, + ), + }, ), ], ), @@ -154,6 +171,52 @@ class _Header extends StatelessWidget { } } +/// Unterreiter im Stil der Referenz-UI: der aktive Reiter ist eine helle +/// Pille, die übrigen sind nur Text. +class _SubTabs extends StatelessWidget { + const _SubTabs({required this.index, required this.onChanged}); + + final int index; + final ValueChanged onChanged; + + static const _labels = ['Songs', 'Künstler', 'Alben']; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), + child: Row( + children: [ + for (var i = 0; i < _labels.length; i++) + Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () => onChanged(i), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 18, vertical: 9), + decoration: BoxDecoration( + color: i == index ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _labels[i], + style: TextStyle( + color: i == index ? Colors.black : Colors.white70, + fontSize: 15, + fontWeight: + i == index ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + /// Waagerecht scrollende Schnellzugriffe (Favoriten, Wiedergabelisten, …). class _QuickAccessRow extends StatelessWidget { const _QuickAccessRow({this.onFavoritesTap}); @@ -210,20 +273,6 @@ class _QuickAccessRow extends StatelessWidget { ); }, ), - _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()), - ), - ), ], ), ); diff --git a/lib/main.dart b/lib/main.dart index 9c46d03..b9b7d80 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -107,12 +107,12 @@ class _HomeShellState extends State { Widget build(BuildContext context) { final tabs = [ MyMusicScreen( - onSearchTap: () => _goTo(1), - onFavoritesTap: () => _goTo(2), + onSearchTap: () => _goTo(2), + onFavoritesTap: () => _goTo(3), ), + const DownloadsScreen(), const SearchScreen(), const FavoritesScreen(), - const DownloadsScreen(), ]; return Scaffold( body: Column( @@ -127,11 +127,11 @@ class _HomeShellState extends State { items: const [ BottomNavigationBarItem( icon: Icon(Icons.headphones), label: 'Meine Musik'), - BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suche'), + BottomNavigationBarItem( + icon: Icon(Icons.music_note_outlined), label: 'Online'), + BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'), BottomNavigationBarItem( icon: Icon(Icons.favorite_border), label: 'Favoriten'), - BottomNavigationBarItem( - icon: Icon(Icons.download_outlined), label: 'Download'), ], ), ); diff --git a/pubspec.lock b/pubspec.lock index 41ba85b..d6fdd6c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -620,10 +620,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -636,10 +636,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -1113,10 +1113,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" typed_data: dependency: transitive description: @@ -1137,10 +1137,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: diff --git a/test/home_shell_test.dart b/test/home_shell_test.dart new file mode 100644 index 0000000..d1ea59c --- /dev/null +++ b/test/home_shell_test.dart @@ -0,0 +1,47 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/category_service.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/library_service.dart'; +import 'package:melo/library/playlist_service.dart'; +import 'package:melo/main.dart'; +import 'package:melo/player/audio_handler.dart'; +import 'package:melo/services/offline_mode.dart'; +import 'package:melo/settings/app_settings.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('Untere Navigation folgt der Referenz-UI: ' + 'Meine Musik, Online, Suchen, Favoriten', (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final lib = LibraryService(db); + final playlists = PlaylistService(db); + final handler = MeloAudioHandler(db: db); + + await tester.pumpWidget(MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: lib), + ChangeNotifierProvider.value(value: playlists), + Provider.value(value: handler), + ChangeNotifierProvider(create: (_) => CategoryService(db)), + ChangeNotifierProvider(create: (_) => AppSettings()), + ChangeNotifierProvider(create: (_) => OfflineMode()), + ], + child: const MaterialApp(home: HomeShell()), + )); + await tester.pump(); + + final bar = tester.widget( + find.byType(BottomNavigationBar)); + expect(bar.items.map((i) => i.label).toList(), + ['Meine Musik', 'Online', 'Suchen', 'Favoriten']); + + handler.dispose(); + await db.close(); + }); +} diff --git a/test/library/my_music_tabs_test.dart b/test/library/my_music_tabs_test.dart new file mode 100644 index 0000000..60c7030 --- /dev/null +++ b/test/library/my_music_tabs_test.dart @@ -0,0 +1,118 @@ +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/category_service.dart'; +import 'package:melo/library/database.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:melo/settings/app_settings.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), + ChangeNotifierProvider(create: (_) => CategoryService(db)), + ChangeNotifierProvider(create: (_) => AppSettings()), + ], + child: MaterialApp(home: Scaffold(body: child)), + ); +} + +Future _insert( + 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() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('Meine Musik hat die Unterreiter Songs, Künstler und Alben', + (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', artist: 'Neoni', album: 'Roses'); + + await tester.pumpWidget( + _wrap(db, lib, playlists, handler, const MyMusicScreen())); + await tester.pumpAndSettle(); + + expect(find.text('Songs'), findsOneWidget); + expect(find.text('Künstler'), findsOneWidget); + expect(find.text('Alben'), findsOneWidget); + + handler.dispose(); + await db.close(); + }); + + testWidgets('Unterreiter Künstler zeigt die Künstlerliste statt der Songs', + (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', artist: 'Neoni', album: 'Roses'); + + await tester.pumpWidget( + _wrap(db, lib, playlists, handler, const MyMusicScreen())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Künstler')); + await tester.pumpAndSettle(); + + expect(find.text('Neoni'), findsOneWidget); + // Unterreiter wechseln den Inhalt, sie öffnen keinen neuen Bildschirm — + // Suchfeld und Reiter bleiben sichtbar. + expect(find.text('Titel, Künstler und Alben suchen'), findsOneWidget); + expect(find.text('Songs'), findsOneWidget); + expect(find.text('Shuffle-Wiedergabe'), findsNothing); + + handler.dispose(); + await db.close(); + }); + + testWidgets('Unterreiter Alben zeigt die Albenliste', (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', artist: 'Neoni', album: 'Roses'); + + await tester.pumpWidget( + _wrap(db, lib, playlists, handler, const MyMusicScreen())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Alben')); + await tester.pumpAndSettle(); + + expect(find.text('Roses'), findsOneWidget); + expect(find.text('Titel, Künstler und Alben suchen'), findsOneWidget); + expect(find.text('Songs'), findsOneWidget); + + handler.dispose(); + await db.close(); + }); +}