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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user