From 23a1e7838d96dfee2a35652f9a5a75d4e545feea Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Tue, 18 Aug 2026 18:31:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(settings):=20Settings-Tab=20mit=20Biblioth?= =?UTF-8?q?eks-Statistik,=20Berechtigungen,=20=C3=9Cber=20Melo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bibliotheks-Statistik: Song-Anzahl + Gesamtspieldauer via watchSongs() (reine Summier-/Format-Funktion in lib/settings/library_stats.dart, unabhängig von Flutter/Plattform testbar) - Berechtigungen: Musik-Berechtigung öffnet Systemeinstellungen über das bereits vorhandene openMusicPermissionSettings() - Über Melo: kurzer Name + Slogan, keine erfundene Versionsnummer (package_info_plus ist keine Dependency) - Keine Platzhalter für Profil/Cloud-Sync/Geräte-Verwaltung, da es noch kein Account-/Cloud-System gibt (Phase 2) - HomeShell nutzt SettingsScreen statt des Settings-Placeholders, toter _Placeholder-Widget-Code entfernt Co-Authored-By: Claude Haiku 4.5 --- lib/main.dart | 23 +------ lib/settings/library_stats.dart | 26 ++++++++ lib/settings/settings_screen.dart | 84 +++++++++++++++++++++++++ test/settings/library_stats_test.dart | 60 ++++++++++++++++++ test/settings/settings_screen_test.dart | 70 +++++++++++++++++++++ 5 files changed, 242 insertions(+), 21 deletions(-) create mode 100644 lib/settings/library_stats.dart create mode 100644 lib/settings/settings_screen.dart create mode 100644 test/settings/library_stats_test.dart create mode 100644 test/settings/settings_screen_test.dart diff --git a/lib/main.dart b/lib/main.dart index 6ce6f39..9a45573 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'player/audio_handler.dart'; import 'player/mini_player.dart'; import 'player/now_playing_screen.dart'; import 'playlists/playlists_screen.dart'; +import 'settings/settings_screen.dart'; import 'shared/theme.dart'; late final MeloAudioHandler _handler; @@ -71,7 +72,7 @@ class _HomeShellState extends State { LibraryScreen(), PlaylistsScreen(), SearchScreen(), - _Placeholder(label: 'Settings', hint: 'Kommt in Kürze'), + SettingsScreen(), ]; @override @@ -100,23 +101,3 @@ class _HomeShellState extends State { ); } } - -class _Placeholder extends StatelessWidget { - const _Placeholder({required this.label, required this.hint}); - final String label; - final String hint; - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(label, style: Theme.of(context).textTheme.headlineSmall), - const SizedBox(height: 8), - Text(hint, style: const TextStyle(color: Colors.white54)), - ], - ), - ); - } -} diff --git a/lib/settings/library_stats.dart b/lib/settings/library_stats.dart new file mode 100644 index 0000000..42a8479 --- /dev/null +++ b/lib/settings/library_stats.dart @@ -0,0 +1,26 @@ +import '../library/database.dart'; + +/// Summiert die Spieldauer aller Songs (in [songs]). Songs ohne bekannte +/// Dauer (`durationMs == null`) werden übersprungen. +Duration totalDuration(List songs) { + var totalMs = 0; + for (final song in songs) { + final durationMs = song.durationMs; + if (durationMs != null) { + totalMs += durationMs; + } + } + return Duration(milliseconds: totalMs); +} + +/// Formatiert die Gesamtspieldauer der Bibliothek als "Xh Ym" bzw. "Ym" bei +/// unter einer Stunde. +String formatTotalDuration(List songs) { + final duration = totalDuration(songs); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + if (hours > 0) { + return '${hours}h ${minutes}m'; + } + return '${minutes}m'; +} diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart new file mode 100644 index 0000000..0544317 --- /dev/null +++ b/lib/settings/settings_screen.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../library/database.dart'; +import '../library/permissions.dart'; +import 'library_stats.dart'; + +/// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Über Melo. +/// +/// Kein Cloud-/Account-System vorhanden (Phase 2) — daher keine +/// Platzhalter-Einträge für Profil/Sync/Geräte, die aktuell ins Leere laufen. +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final db = context.read(); + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ListView( + children: [ + const _SectionLabel('Bibliothek'), + StreamBuilder>( + stream: db.watchSongs(), + builder: (context, snapshot) { + final songs = snapshot.data ?? const []; + return Column( + children: [ + ListTile( + leading: const Icon(Icons.library_music), + title: Text('${songs.length} Songs in der Bibliothek'), + ), + ListTile( + leading: const Icon(Icons.schedule), + title: + Text('${formatTotalDuration(songs)} Gesamtspieldauer'), + ), + ], + ); + }, + ), + const Divider(height: 1), + const _SectionLabel('Berechtigungen'), + ListTile( + leading: const Icon(Icons.mic_none), + title: const Text('Musik-Berechtigung'), + trailing: TextButton( + onPressed: openMusicPermissionSettings, + child: const Text('Einstellungen öffnen'), + ), + ), + const Divider(height: 1), + const _SectionLabel('Über Melo'), + const ListTile( + leading: Icon(Icons.info_outline), + title: Text('Melo'), + subtitle: Text('Deine Musik. Offline. Kein Abo.'), + ), + ], + ), + ); + } +} + +/// Kleine graue Überschrift über einer Einstellungs-Gruppe. +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.label); + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + label, + style: const TextStyle( + color: Colors.white54, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} diff --git a/test/settings/library_stats_test.dart b/test/settings/library_stats_test.dart new file mode 100644 index 0000000..ebdbd61 --- /dev/null +++ b/test/settings/library_stats_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/settings/library_stats.dart'; + +Song _song({required String id, int? durationMs}) { + return Song( + id: id, + path: '/$id.mp3', + title: id, + durationMs: durationMs, + dateAddedMs: 0, + updatedAtMs: 0, + deleted: false, + ); +} + +void main() { + group('totalDuration', () { + test('leere Liste ergibt Duration.zero', () { + expect(totalDuration([]), Duration.zero); + }); + + test('überspringt Songs ohne bekannte Dauer', () { + final songs = [ + _song(id: 'a', durationMs: 200000), + _song(id: 'b', durationMs: 185000), + _song(id: 'c', durationMs: null), + ]; + expect(totalDuration(songs), const Duration(milliseconds: 385000)); + }); + }); + + group('formatTotalDuration', () { + test('leere Liste ergibt "0m"', () { + expect(formatTotalDuration([]), '0m'); + }); + + test('formatiert unter einer Stunde ohne Stunden-Anteil', () { + final songs = [_song(id: 'a', durationMs: 6 * 60 * 1000)]; + expect(formatTotalDuration(songs), '6m'); + }); + + test('formatiert Stunden und Minuten korrekt', () { + // 200000ms + 185000ms = 385000ms = 6min 25s -> aufgerundet auf 6 Minuten + final songs = [ + _song(id: 'a', durationMs: 200000), + _song(id: 'b', durationMs: 185000), + _song(id: 'c', durationMs: null), + ]; + expect(formatTotalDuration(songs), '6m'); + }); + + test('formatiert mehr als eine Stunde mit Stunden-Anteil', () { + final songs = [ + _song(id: 'a', durationMs: const Duration(hours: 2, minutes: 15).inMilliseconds), + ]; + expect(formatTotalDuration(songs), '2h 15m'); + }); + }); +} diff --git a/test/settings/settings_screen_test.dart b/test/settings/settings_screen_test.dart new file mode 100644 index 0000000..3945597 --- /dev/null +++ b/test/settings/settings_screen_test.dart @@ -0,0 +1,70 @@ +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/settings/settings_screen.dart'; + +Widget _wrap(MeloDb db, Widget child) { + return MultiProvider( + providers: [ + Provider.value(value: db), + ], + child: MaterialApp(home: child), + ); +} + +void main() { + testWidgets('zeigt 0 Songs ohne Bibliothek', (tester) async { + final db = MeloDb(NativeDatabase.memory()); + + await tester.pumpWidget(_wrap(db, const SettingsScreen())); + await tester.pumpAndSettle(); + + expect(find.text('0 Songs in der Bibliothek'), findsOneWidget); + + await db.close(); + }); + + testWidgets('zeigt korrekte Song-Anzahl und Gesamtspieldauer', (tester) async { + final db = MeloDb(NativeDatabase.memory()); + await db.into(db.songs).insert(SongsCompanion.insert( + id: 'song-1', + path: '/a.mp3', + title: 'Song A', + dateAddedMs: 0, + updatedAtMs: 0, + durationMs: const Value(200000), + )); + await db.into(db.songs).insert(SongsCompanion.insert( + id: 'song-2', + path: '/b.mp3', + title: 'Song B', + dateAddedMs: 0, + updatedAtMs: 0, + durationMs: const Value(185000), + )); + + await tester.pumpWidget(_wrap(db, const SettingsScreen())); + await tester.pumpAndSettle(); + + expect(find.text('2 Songs in der Bibliothek'), findsOneWidget); + expect(find.text('6m Gesamtspieldauer'), findsOneWidget); + + await db.close(); + }); + + testWidgets('zeigt Musik-Berechtigung und Über-Melo Abschnitt', (tester) async { + final db = MeloDb(NativeDatabase.memory()); + + await tester.pumpWidget(_wrap(db, const SettingsScreen())); + await tester.pumpAndSettle(); + + expect(find.text('Musik-Berechtigung'), findsOneWidget); + expect(find.text('Melo'), findsOneWidget); + expect(find.text('Deine Musik. Offline. Kein Abo.'), findsOneWidget); + + await db.close(); + }); +}