Neue Melo-UI Phase 1: 4 Tabs (Meine Musik/Suche/Favoriten/Download) + Sortierung

- Meine Musik als Startbildschirm ohne Topbar: Kopfzeile (Einstellungen,
  Suche, Musikerkennung), Schnellzugriffe, Shuffle + Sortieren, Liederliste
- Sortierung nach Zuletzt hinzugefuegt / Name (A-Z-#) / Wie oft abgespielt,
  auf- und absteigend, pro Liste gespeichert (SortStore)
- Favoriten als eigener Tab mit gleicher Shuffle-/Sortier-Leiste
- Download-Tab uebernimmt den Navidrome-Server-Browser
- DB-Schema 3: playCount, gezaehlt beim Titelstart
- Theme auf #0B0B10 / #c0392b
- Scan-Aktionen aus der entfallenen Topbar jetzt in den Einstellungen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011snPXPafPC5i87o68H14W7
This commit is contained in:
2026-08-20 18:00:53 +02:00
co-authored by Claude Opus 5
parent 5d08da1086
commit 2667613a2e
24 changed files with 1413 additions and 372 deletions
+38
View File
@@ -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<SortSetting> 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<void> 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);
}
}
+236
View File
@@ -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<Song> songs;
final String storeKey;
/// Wird statt der Liste gezeigt, wenn [songs] leer ist.
final Widget? empty;
@override
State<SortableSongList> createState() => _SortableSongListState();
}
class _SortableSongListState extends State<SortableSongList> {
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<void> _shuffle() async {
if (widget.songs.isEmpty) return;
final handler = context.read<MeloAudioHandler>();
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<void> _chooseSort() async {
final chosen = await showModalBottomSheet<SortSetting>(
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<bool>(
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'),
),
),
),
],
),
),
);
}
}
+6 -3
View File
@@ -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(