Tab-Struktur nach Referenz-UI: Online-Tab + Unterreiter Songs/Kuenstler/Alben

Untere Navigation folgt jetzt den Referenzbildern:
Meine Musik / Online / Suchen / Favoriten. Der bisherige "Download"-Tab
heisst "Online" und bekommt als Naechstes den YouTube-Downloader.

In "Meine Musik" gibt es Unterreiter (Songs/Kuenstler/Alben) im Pillen-Stil
der Referenz. Sie wechseln nur den Inhalt statt einen Bildschirm zu oeffnen;
die Schnellzugriff-Kacheln "Kuenstler"/"Alben" entfallen dadurch, ebenso die
damit tot gewordenen ArtistsScreen/AlbumsScreen-Huellen.

158 Tests gruen, flutter analyze ohne Befund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NLW1q1hzEC9goDQ1dJ98d
This commit is contained in:
Hermes (Server)
2026-08-20 19:00:16 +02:00
co-authored by Claude Opus 5
parent 4ebd0a8dd4
commit a625d70da8
10 changed files with 285 additions and 78 deletions
+19
View File
@@ -5,6 +5,25 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [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) ### 🎼 Songtext, Warteschlange, Sperrbildschirm-Cover und gleiche Lautstärke (2026-08-20)
- 📝 **Automatischer Songtext:** Beim Scan wird der Songtext aus dem Tag der - 📝 **Automatischer Songtext:** Beim Scan wird der Songtext aus dem Tag der
Datei mitgelesen und in der Wiedergabe sofort angezeigt — **ohne Server und Datei mitgelesen und in der Wiedergabe sofort angezeigt — **ohne Server und
+2 -2
View File
@@ -5,7 +5,7 @@ import 'package:provider/provider.dart';
import '../player/audio_handler.dart'; import '../player/audio_handler.dart';
import '../services/navidrome_service.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 /// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert
/// und stehen danach offline zur Verfügung. /// und stehen danach offline zur Verfügung.
class DownloadsScreen extends StatelessWidget { class DownloadsScreen extends StatelessWidget {
@@ -21,7 +21,7 @@ class DownloadsScreen extends StatelessWidget {
padding: EdgeInsets.fromLTRB(20, 16, 20, 4), padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text('Download', child: Text('Online',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)), style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
), ),
), ),
-13
View File
@@ -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(),
);
}
}
-13
View File
@@ -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(),
);
}
}
+70 -21
View File
@@ -15,9 +15,9 @@ import 'permissions.dart';
import 'song_list.dart'; import 'song_list.dart';
/// Startbildschirm: Kopfzeile mit Einstellungen/Suche/Musikerkennung, /// Startbildschirm: Kopfzeile mit Einstellungen/Suche/Musikerkennung,
/// Schnellzugriffe, darunter Shuffle + sortierbare Liederliste. /// Schnellzugriffe, Unterreiter (Songs/Künstler/Alben) und darunter der
/// Bewusst ohne AppBar — die Kopfzeile ist Teil des Inhalts. /// jeweilige Inhalt. Bewusst ohne AppBar — die Kopfzeile ist Teil des Inhalts.
class MyMusicScreen extends StatelessWidget { class MyMusicScreen extends StatefulWidget {
const MyMusicScreen({super.key, this.onSearchTap, this.onFavoritesTap}); const MyMusicScreen({super.key, this.onSearchTap, this.onFavoritesTap});
/// Wechselt zum Suche-Tab; die Suche ist ein eigener Tab, kein eigener Screen. /// 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. /// Wechselt zum Favoriten-Tab.
final VoidCallback? onFavoritesTap; final VoidCallback? onFavoritesTap;
@override
State<MyMusicScreen> createState() => _MyMusicScreenState();
}
class _MyMusicScreenState extends State<MyMusicScreen> {
/// 0 = Songs, 1 = Künstler, 2 = Alben.
int _subTab = 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final db = context.read<MeloDb>(); final db = context.read<MeloDb>();
@@ -34,7 +42,7 @@ class MyMusicScreen extends StatelessWidget {
bottom: false, bottom: false,
child: Column( child: Column(
children: [ children: [
_Header(onSearchTap: onSearchTap), _Header(onSearchTap: widget.onSearchTap),
if (lib.permissionDenied) if (lib.permissionDenied)
MaterialBanner( MaterialBanner(
backgroundColor: Colors.red.shade900, backgroundColor: Colors.red.shade900,
@@ -73,9 +81,16 @@ class MyMusicScreen extends StatelessWidget {
], ],
), ),
), ),
_QuickAccessRow(onFavoritesTap: onFavoritesTap), _QuickAccessRow(onFavoritesTap: widget.onFavoritesTap),
_SubTabs(
index: _subTab,
onChanged: (i) => setState(() => _subTab = i),
),
Expanded( Expanded(
child: StreamBuilder<List<Song>>( child: switch (_subTab) {
1 => const ArtistListScreen(),
2 => const AlbumListScreen(),
_ => StreamBuilder<List<Song>>(
stream: db.watchSongs(), stream: db.watchSongs(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) { if (snapshot.hasError) {
@@ -88,10 +103,12 @@ class MyMusicScreen extends StatelessWidget {
return SortableSongList( return SortableSongList(
songs: songs, songs: songs,
storeKey: SortStore.meineMusik, storeKey: SortStore.meineMusik,
empty: lib.scanning ? const SizedBox.shrink() : const _Empty(), 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<int> 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, …). /// Waagerecht scrollende Schnellzugriffe (Favoriten, Wiedergabelisten, …).
class _QuickAccessRow extends StatelessWidget { class _QuickAccessRow extends StatelessWidget {
const _QuickAccessRow({this.onFavoritesTap}); 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()),
),
),
], ],
), ),
); );
+6 -6
View File
@@ -107,12 +107,12 @@ class _HomeShellState extends State<HomeShell> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tabs = <Widget>[ final tabs = <Widget>[
MyMusicScreen( MyMusicScreen(
onSearchTap: () => _goTo(1), onSearchTap: () => _goTo(2),
onFavoritesTap: () => _goTo(2), onFavoritesTap: () => _goTo(3),
), ),
const DownloadsScreen(),
const SearchScreen(), const SearchScreen(),
const FavoritesScreen(), const FavoritesScreen(),
const DownloadsScreen(),
]; ];
return Scaffold( return Scaffold(
body: Column( body: Column(
@@ -127,11 +127,11 @@ class _HomeShellState extends State<HomeShell> {
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: 'Suche'), BottomNavigationBarItem(
icon: Icon(Icons.music_note_outlined), label: 'Online'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.favorite_border), label: 'Favoriten'), icon: Icon(Icons.favorite_border), label: 'Favoriten'),
BottomNavigationBarItem(
icon: Icon(Icons.download_outlined), label: 'Download'),
], ],
), ),
); );
+8 -8
View File
@@ -620,10 +620,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.19" version: "0.12.20"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -636,10 +636,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.19.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -1113,10 +1113,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.12"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -1137,10 +1137,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.4.2"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
+47
View File
@@ -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<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: lib),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
],
child: const MaterialApp(home: HomeShell()),
));
await tester.pump();
final bar = tester.widget<BottomNavigationBar>(
find.byType(BottomNavigationBar));
expect(bar.items.map((i) => i.label).toList(),
['Meine Musik', 'Online', 'Suchen', 'Favoriten']);
handler.dispose();
await db.close();
});
}
+118
View File
@@ -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<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: lib),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
],
child: MaterialApp(home: Scaffold(body: child)),
);
}
Future<void> _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();
});
}