From b2ac118b42917c7bad825a5d29ca1fe222d0459d Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Tue, 18 Aug 2026 17:18:56 +0200 Subject: [PATCH] feat(playlists): add Playlisten-Tab mit Favoriten-Kurzeinstieg und Erstellen-Dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlaylistsScreen: Favoriten-ListTile oben (watchFavorites, pusht FavoritesListScreen), darunter alle Playlisten (watchPlaylists) mit Songanzahl, Löschen via Icon/Long-Press + Bestätigungsdialog - CreatePlaylistDialog: Name-Eingabe, Erstellen-Button erst aktiv bei nicht-leerem Text, gibt die neue Playlist-ID zurück - Leer-Zustand "Noch keine Playlisten" nach Vorbild von library_screen.dart - main.dart: Playlisten-Platzhalter durch PlaylistsScreen ersetzt Co-Authored-By: Claude Haiku 4.5 --- lib/main.dart | 3 +- lib/playlists/create_playlist_dialog.dart | 55 +++++++ lib/playlists/playlists_screen.dart | 171 ++++++++++++++++++++++ test/playlists/playlists_screen_test.dart | 45 ++++++ 4 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 lib/playlists/create_playlist_dialog.dart create mode 100644 lib/playlists/playlists_screen.dart create mode 100644 test/playlists/playlists_screen_test.dart diff --git a/lib/main.dart b/lib/main.dart index db5c8e4..6bf193d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ 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 'shared/theme.dart'; late final MeloAudioHandler _handler; @@ -68,7 +69,7 @@ class _HomeShellState extends State { static const _tabs = [ NowPlayingScreen(), LibraryScreen(), - _Placeholder(label: 'Playlisten', hint: 'Kommt in Kürze'), + PlaylistsScreen(), SearchScreen(), _Placeholder(label: 'Settings', hint: 'Kommt in Kürze'), ]; diff --git a/lib/playlists/create_playlist_dialog.dart b/lib/playlists/create_playlist_dialog.dart new file mode 100644 index 0000000..b85d271 --- /dev/null +++ b/lib/playlists/create_playlist_dialog.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../library/playlist_service.dart'; + +/// Dialog zum Anlegen einer neuen Playlist. Gibt bei Erfolg die neue +/// Playlist-ID zurück (z.B. um direkt danach einen Song hinzuzufügen). +class CreatePlaylistDialog extends StatefulWidget { + const CreatePlaylistDialog({super.key}); + + @override + State createState() => _CreatePlaylistDialogState(); +} + +class _CreatePlaylistDialogState extends State { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _create() async { + final name = _controller.text.trim(); + if (name.isEmpty) return; + final id = await context.read().createPlaylist(name); + if (mounted) Navigator.pop(context, id); + } + + @override + Widget build(BuildContext context) { + final canCreate = _controller.text.trim().isNotEmpty; + return AlertDialog( + title: const Text('Neue Playlist'), + content: TextField( + controller: _controller, + autofocus: true, + decoration: const InputDecoration(hintText: 'Name der Playlist'), + onChanged: (_) => setState(() {}), + onSubmitted: (_) => canCreate ? _create() : null, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Abbrechen'), + ), + TextButton( + onPressed: canCreate ? _create : null, + child: const Text('Erstellen'), + ), + ], + ); + } +} diff --git a/lib/playlists/playlists_screen.dart b/lib/playlists/playlists_screen.dart new file mode 100644 index 0000000..55de201 --- /dev/null +++ b/lib/playlists/playlists_screen.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../library/database.dart'; +import '../library/playlist_service.dart'; +import '../library/song_list.dart'; +import '../shared/theme.dart'; +import 'create_playlist_dialog.dart'; +import 'playlist_detail_screen.dart'; + +/// Playlisten-Tab: Favoriten-Kurzeinstieg oben, darunter alle eigenen Playlisten. +class PlaylistsScreen extends StatelessWidget { + const PlaylistsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final db = context.read(); + return Scaffold( + appBar: AppBar(title: const Text('Playlisten')), + body: Column( + children: [ + StreamBuilder>( + stream: db.watchFavorites(), + builder: (context, snapshot) { + final count = snapshot.data?.length ?? 0; + return ListTile( + leading: const Icon(Icons.favorite, color: MeloTheme.red), + title: const Text('Favoriten'), + subtitle: Text('$count Songs'), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const FavoritesListScreen()), + ), + ); + }, + ), + const Divider(height: 1), + Expanded( + child: StreamBuilder>( + stream: db.watchPlaylists(), + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text('Fehler: ${snapshot.error}', + style: const TextStyle(color: Colors.white54)), + ); + } + final playlists = snapshot.data ?? const []; + if (playlists.isEmpty) { + return const _Empty(); + } + return ListView.builder( + itemCount: playlists.length, + itemBuilder: (context, i) => _PlaylistTile(playlist: playlists[i]), + ); + }, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton( + tooltip: 'Neue Playlist', + onPressed: () => showDialog( + context: context, + builder: (_) => const CreatePlaylistDialog(), + ), + child: const Icon(Icons.add), + ), + ); + } +} + +class _PlaylistTile extends StatelessWidget { + const _PlaylistTile({required this.playlist}); + final Playlist playlist; + + Future _confirmDelete(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Playlist löschen?'), + content: Text('„${playlist.name}“ wird dauerhaft gelöscht.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Abbrechen'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Löschen'), + ), + ], + ), + ); + if (confirmed == true && context.mounted) { + await context.read().deletePlaylist(playlist.id); + } + } + + @override + Widget build(BuildContext context) { + final db = context.read(); + return StreamBuilder>( + stream: db.watchPlaylistSongs(playlist.id), + builder: (context, snapshot) { + final count = snapshot.data?.length ?? 0; + return ListTile( + leading: const Icon(Icons.playlist_play), + title: Text(playlist.name), + subtitle: Text('$count Songs'), + trailing: IconButton( + tooltip: 'Playlist löschen', + icon: const Icon(Icons.delete_outline), + onPressed: () => _confirmDelete(context), + ), + onLongPress: () => _confirmDelete(context), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => PlaylistDetailScreen(playlist: playlist)), + ), + ); + }, + ); + } +} + +class _Empty extends StatelessWidget { + const _Empty(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.playlist_play, size: 64, color: Colors.white24), + SizedBox(height: 12), + Text('Noch keine Playlisten', style: TextStyle(color: Colors.white54)), + SizedBox(height: 8), + Text('Tippe auf + um eine Playlist zu erstellen', + style: TextStyle(color: Colors.white38, fontSize: 12)), + ], + ), + ); + } +} + +/// Gepushte Ansicht der favorisierten Songs. +class FavoritesListScreen extends StatelessWidget { + const FavoritesListScreen({super.key}); + + @override + Widget build(BuildContext context) { + final db = context.read(); + return Scaffold( + appBar: AppBar(title: const Text('Favoriten')), + body: StreamBuilder>( + stream: db.watchFavorites(), + builder: (context, snapshot) { + final songs = snapshot.data ?? const []; + if (songs.isEmpty) { + return const Center( + child: Text('Noch keine Favoriten', style: TextStyle(color: Colors.white54)), + ); + } + return SongList(songs); + }, + ), + ); + } +} diff --git a/test/playlists/playlists_screen_test.dart b/test/playlists/playlists_screen_test.dart new file mode 100644 index 0000000..50752bc --- /dev/null +++ b/test/playlists/playlists_screen_test.dart @@ -0,0 +1,45 @@ +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/playlist_service.dart'; +import 'package:melo/playlists/playlists_screen.dart'; + +Widget _wrap(MeloDb db, PlaylistService service, Widget child) { + return MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: service), + ], + child: MaterialApp(home: child), + ); +} + +void main() { + testWidgets('zeigt Leer-Zustand ohne Playlisten', (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final service = PlaylistService(db); + + await tester.pumpWidget(_wrap(db, service, const PlaylistsScreen())); + await tester.pumpAndSettle(); + + expect(find.text('Noch keine Playlisten'), findsOneWidget); + + await db.close(); + }); + + testWidgets('zeigt angelegte Playlist in der Liste', (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final service = PlaylistService(db); + await db.createPlaylist('Road Trip'); + + await tester.pumpWidget(_wrap(db, service, const PlaylistsScreen())); + await tester.pumpAndSettle(); + + expect(find.text('Road Trip'), findsOneWidget); + expect(find.text('0 Songs'), findsWidgets); + + await db.close(); + }); +}