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
@@ -4,256 +4,42 @@ import 'package:provider/provider.dart';
import '../player/audio_handler.dart';
import '../services/navidrome_service.dart';
import '../shared/cover.dart';
import 'album_list.dart';
import 'artist_list.dart';
import 'database.dart';
import 'library_service.dart';
import 'permissions.dart';
import 'song_list.dart';
import 'song_media.dart';
/// Bibliotheks-Tab mit drei Unteransichten: alle Songs, Künstler, Alben.
/// Scan-Banner/-Fortschritt gelten für alle Unteransichten und liegen daher
/// oberhalb der [TabBarView].
class LibraryScreen extends StatelessWidget {
const LibraryScreen({super.key});
/// Download-Tab: durchsucht den verbundenen Navidrome-Server. Abgespielte
/// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert
/// und stehen danach offline zur Verfügung.
class DownloadsScreen extends StatelessWidget {
const DownloadsScreen({super.key});
@override
Widget build(BuildContext context) {
final lib = context.watch<LibraryService>();
return DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: const Text('Bibliothek'),
actions: [
IconButton(
tooltip: addMusicLabel,
icon: const Icon(Icons.create_new_folder_outlined),
onPressed: lib.scanning ? null : lib.pickFolderAndScan,
),
IconButton(
tooltip: 'Erneut scannen',
icon: const Icon(Icons.refresh),
onPressed: lib.scanning ? null : lib.rescan,
),
],
bottom: const TabBar(
// Engere Abstände, damit '🌐 Server' nicht abgeschnitten wird
// (4 feste Tabs, gleich breit) — Schriftgröße bleibt unverändert.
labelPadding: EdgeInsets.symmetric(horizontal: 4),
tabs: [
Tab(text: 'Alle'),
Tab(text: 'Künstler'),
Tab(text: 'Alben'),
Tab(text: '🌐 Server'),
],
),
),
body: Column(
children: [
if (lib.permissionDenied)
MaterialBanner(
backgroundColor: Colors.red.shade900,
content: const Text('Zugriff auf Musik verweigert. '
'Bitte in den Einstellungen erlauben.'),
actions: [
TextButton(
onPressed: openMusicPermissionSettings,
child: const Text('Einstellungen'),
),
],
),
if (lib.scanError != null)
MaterialBanner(
backgroundColor: Colors.red.shade900,
content: Text(lib.scanError!),
actions: [
TextButton(
onPressed: lib.dismissScanError,
child: const Text('Verwerfen'),
),
],
),
if (lib.scanning)
Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
const LinearProgressIndicator(),
const SizedBox(height: 6),
Text(
'Scanne … ${lib.scanDone}'
'${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}',
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
Expanded(
child: TabBarView(
children: [
const _AllSongsTab(),
const ArtistListScreen(),
const AlbumListScreen(),
const _NavidromeTab(),
],
),
),
],
),
),
);
}
}
/// "Alle"-Tab: zuletzt hinzugefügt + vollständige Songliste (unverändertes
/// Verhalten aus der Zeit vor der Künstler-/Alben-Aufteilung).
class _AllSongsTab extends StatelessWidget {
const _AllSongsTab();
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
final lib = context.watch<LibraryService>();
return Column(
children: [
StreamBuilder<List<Song>>(
stream: db.watchRecent(limit: 10),
builder: (context, snapshot) {
final recent = snapshot.data ?? const [];
if (recent.isEmpty || lib.scanning) return const SizedBox.shrink();
return _RecentlyAdded(songs: recent);
},
),
Expanded(
child: StreamBuilder<List<Song>>(
stream: db.watchSongs(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: Colors.white54)),
);
}
final songs = snapshot.data ?? const [];
if (songs.isEmpty && !lib.scanning) {
return const _Empty();
}
return SongList(songs);
},
),
),
],
);
}
}
/// Horizontal scrollende Leiste mit den zuletzt hinzugefügten Songs.
class _RecentlyAdded extends StatelessWidget {
const _RecentlyAdded({required this.songs});
final List<Song> songs;
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Zuletzt hinzugefügt',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
),
SizedBox(
height: 140,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: songs.length,
itemBuilder: (context, i) {
final s = songs[i];
return Padding(
padding: const EdgeInsets.only(right: 12),
child: SizedBox(
width: 96,
child: InkWell(
onTap: () async {
try {
await playSongs(handler, songs, i);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Wiedergabe fehlgeschlagen: $e')),
);
}
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CoverImage(
artUri:
s.coverPath != null ? Uri.file(s.coverPath!) : null,
size: 96,
radius: 8,
),
const SizedBox(height: 4),
Text(
s.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
],
),
),
),
);
},
),
),
],
);
}
}
class _Empty extends StatelessWidget {
const _Empty();
@override
Widget build(BuildContext context) {
final lib = context.read<LibraryService>();
return Center(
return const SafeArea(
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.library_music, size: 64, color: Colors.white24),
const SizedBox(height: 12),
const Text('Noch keine Musik',
style: TextStyle(color: Colors.white54)),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.create_new_folder_outlined),
label: Text(addMusicLabel),
onPressed: lib.pickFolderAndScan,
Padding(
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('Download',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
),
),
Expanded(child: _ServerBrowser()),
],
),
);
}
}
class _NavidromeTab extends StatefulWidget {
const _NavidromeTab();
class _ServerBrowser extends StatefulWidget {
const _ServerBrowser();
@override
State<_NavidromeTab> createState() => _NavidromeTabState();
State<_ServerBrowser> createState() => _ServerBrowserState();
}
class _NavidromeTabState extends State<_NavidromeTab> {
class _ServerBrowserState extends State<_ServerBrowser> {
late final NavidromeService _nav = NavidromeService();
List<SubsonicAlbum> _albums = [];
List<SubsonicArtist> _artists = [];
+13
View File
@@ -67,3 +67,16 @@ 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,3 +59,16 @@ 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(),
);
}
}
+19 -1
View File
@@ -22,6 +22,10 @@ class Songs extends Table {
IntColumn get updatedAtMs => integer()();
BoolColumn get deleted => boolean().withDefault(const Constant(false))();
/// Wie oft der Song vollständig gestartet wurde — Grundlage für die
/// Sortierung "Wie oft abgespielt".
IntColumn get playCount => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id};
}
@@ -85,7 +89,7 @@ class MeloDb extends _$MeloDb {
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
@override
int get schemaVersion => 2;
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -97,6 +101,9 @@ class MeloDb extends _$MeloDb {
await m.createTable(favorites);
await m.createTable(playbackHistory);
}
if (from < 3) {
await m.addColumn(songs, songs.playCount);
}
},
);
@@ -291,6 +298,17 @@ class MeloDb extends _$MeloDb {
}
// === Playback History ===
/// Zählt eine Wiedergabe. Wird beim Start eines Tracks aufgerufen, nicht beim
/// periodischen Speichern der Position — sonst würde der Zähler hochlaufen,
/// solange ein Song nur läuft.
Future<void> incrementPlayCount(String songId) async {
await customUpdate(
'UPDATE songs SET play_count = play_count + 1 WHERE id = ?',
variables: [Variable<String>(songId)],
updates: {songs},
);
}
Future<void> recordPlayback(String songId, int positionMs) async {
await into(playbackHistory).insert(PlaybackHistoryCompanion.insert(
songId: songId,
+70 -2
View File
@@ -113,6 +113,18 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
),
defaultValue: const Constant(false),
);
static const VerificationMeta _playCountMeta = const VerificationMeta(
'playCount',
);
@override
late final GeneratedColumn<int> playCount = GeneratedColumn<int>(
'play_count',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const Constant(0),
);
@override
List<GeneratedColumn> get $columns => [
id,
@@ -125,6 +137,7 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
dateAddedMs,
updatedAtMs,
deleted,
playCount,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -211,6 +224,12 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
deleted.isAcceptableOrUnknown(data['deleted']!, _deletedMeta),
);
}
if (data.containsKey('play_count')) {
context.handle(
_playCountMeta,
playCount.isAcceptableOrUnknown(data['play_count']!, _playCountMeta),
);
}
return context;
}
@@ -260,6 +279,10 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
DriftSqlType.bool,
data['${effectivePrefix}deleted'],
)!,
playCount: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}play_count'],
)!,
);
}
@@ -280,6 +303,10 @@ class Song extends DataClass implements Insertable<Song> {
final int dateAddedMs;
final int updatedAtMs;
final bool deleted;
/// Wie oft der Song vollständig gestartet wurde — Grundlage für die
/// Sortierung "Wie oft abgespielt".
final int playCount;
const Song({
required this.id,
required this.path,
@@ -291,6 +318,7 @@ class Song extends DataClass implements Insertable<Song> {
required this.dateAddedMs,
required this.updatedAtMs,
required this.deleted,
required this.playCount,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -313,6 +341,7 @@ class Song extends DataClass implements Insertable<Song> {
map['date_added_ms'] = Variable<int>(dateAddedMs);
map['updated_at_ms'] = Variable<int>(updatedAtMs);
map['deleted'] = Variable<bool>(deleted);
map['play_count'] = Variable<int>(playCount);
return map;
}
@@ -336,6 +365,7 @@ class Song extends DataClass implements Insertable<Song> {
dateAddedMs: Value(dateAddedMs),
updatedAtMs: Value(updatedAtMs),
deleted: Value(deleted),
playCount: Value(playCount),
);
}
@@ -355,6 +385,7 @@ class Song extends DataClass implements Insertable<Song> {
dateAddedMs: serializer.fromJson<int>(json['dateAddedMs']),
updatedAtMs: serializer.fromJson<int>(json['updatedAtMs']),
deleted: serializer.fromJson<bool>(json['deleted']),
playCount: serializer.fromJson<int>(json['playCount']),
);
}
@override
@@ -371,6 +402,7 @@ class Song extends DataClass implements Insertable<Song> {
'dateAddedMs': serializer.toJson<int>(dateAddedMs),
'updatedAtMs': serializer.toJson<int>(updatedAtMs),
'deleted': serializer.toJson<bool>(deleted),
'playCount': serializer.toJson<int>(playCount),
};
}
@@ -385,6 +417,7 @@ class Song extends DataClass implements Insertable<Song> {
int? dateAddedMs,
int? updatedAtMs,
bool? deleted,
int? playCount,
}) => Song(
id: id ?? this.id,
path: path ?? this.path,
@@ -396,6 +429,7 @@ class Song extends DataClass implements Insertable<Song> {
dateAddedMs: dateAddedMs ?? this.dateAddedMs,
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
deleted: deleted ?? this.deleted,
playCount: playCount ?? this.playCount,
);
Song copyWithCompanion(SongsCompanion data) {
return Song(
@@ -415,6 +449,7 @@ class Song extends DataClass implements Insertable<Song> {
? data.updatedAtMs.value
: this.updatedAtMs,
deleted: data.deleted.present ? data.deleted.value : this.deleted,
playCount: data.playCount.present ? data.playCount.value : this.playCount,
);
}
@@ -430,7 +465,8 @@ class Song extends DataClass implements Insertable<Song> {
..write('coverPath: $coverPath, ')
..write('dateAddedMs: $dateAddedMs, ')
..write('updatedAtMs: $updatedAtMs, ')
..write('deleted: $deleted')
..write('deleted: $deleted, ')
..write('playCount: $playCount')
..write(')'))
.toString();
}
@@ -447,6 +483,7 @@ class Song extends DataClass implements Insertable<Song> {
dateAddedMs,
updatedAtMs,
deleted,
playCount,
);
@override
bool operator ==(Object other) =>
@@ -461,7 +498,8 @@ class Song extends DataClass implements Insertable<Song> {
other.coverPath == this.coverPath &&
other.dateAddedMs == this.dateAddedMs &&
other.updatedAtMs == this.updatedAtMs &&
other.deleted == this.deleted);
other.deleted == this.deleted &&
other.playCount == this.playCount);
}
class SongsCompanion extends UpdateCompanion<Song> {
@@ -475,6 +513,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
final Value<int> dateAddedMs;
final Value<int> updatedAtMs;
final Value<bool> deleted;
final Value<int> playCount;
final Value<int> rowid;
const SongsCompanion({
this.id = const Value.absent(),
@@ -487,6 +526,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
this.dateAddedMs = const Value.absent(),
this.updatedAtMs = const Value.absent(),
this.deleted = const Value.absent(),
this.playCount = const Value.absent(),
this.rowid = const Value.absent(),
});
SongsCompanion.insert({
@@ -500,6 +540,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
required int dateAddedMs,
required int updatedAtMs,
this.deleted = const Value.absent(),
this.playCount = const Value.absent(),
this.rowid = const Value.absent(),
}) : id = Value(id),
path = Value(path),
@@ -517,6 +558,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
Expression<int>? dateAddedMs,
Expression<int>? updatedAtMs,
Expression<bool>? deleted,
Expression<int>? playCount,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
@@ -530,6 +572,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
if (dateAddedMs != null) 'date_added_ms': dateAddedMs,
if (updatedAtMs != null) 'updated_at_ms': updatedAtMs,
if (deleted != null) 'deleted': deleted,
if (playCount != null) 'play_count': playCount,
if (rowid != null) 'rowid': rowid,
});
}
@@ -545,6 +588,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
Value<int>? dateAddedMs,
Value<int>? updatedAtMs,
Value<bool>? deleted,
Value<int>? playCount,
Value<int>? rowid,
}) {
return SongsCompanion(
@@ -558,6 +602,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
dateAddedMs: dateAddedMs ?? this.dateAddedMs,
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
deleted: deleted ?? this.deleted,
playCount: playCount ?? this.playCount,
rowid: rowid ?? this.rowid,
);
}
@@ -595,6 +640,9 @@ class SongsCompanion extends UpdateCompanion<Song> {
if (deleted.present) {
map['deleted'] = Variable<bool>(deleted.value);
}
if (playCount.present) {
map['play_count'] = Variable<int>(playCount.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
@@ -614,6 +662,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
..write('dateAddedMs: $dateAddedMs, ')
..write('updatedAtMs: $updatedAtMs, ')
..write('deleted: $deleted, ')
..write('playCount: $playCount, ')
..write('rowid: $rowid')
..write(')'))
.toString();
@@ -2223,6 +2272,7 @@ typedef $$SongsTableCreateCompanionBuilder =
required int dateAddedMs,
required int updatedAtMs,
Value<bool> deleted,
Value<int> playCount,
Value<int> rowid,
});
typedef $$SongsTableUpdateCompanionBuilder =
@@ -2237,6 +2287,7 @@ typedef $$SongsTableUpdateCompanionBuilder =
Value<int> dateAddedMs,
Value<int> updatedAtMs,
Value<bool> deleted,
Value<int> playCount,
Value<int> rowid,
});
@@ -2359,6 +2410,11 @@ class $$SongsTableFilterComposer extends Composer<_$MeloDb, $SongsTable> {
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get playCount => $composableBuilder(
column: $table.playCount,
builder: (column) => ColumnFilters(column),
);
Expression<bool> playlistSongsRefs(
Expression<bool> Function($$PlaylistSongsTableFilterComposer f) f,
) {
@@ -2492,6 +2548,11 @@ class $$SongsTableOrderingComposer extends Composer<_$MeloDb, $SongsTable> {
column: $table.deleted,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get playCount => $composableBuilder(
column: $table.playCount,
builder: (column) => ColumnOrderings(column),
);
}
class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> {
@@ -2538,6 +2599,9 @@ class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> {
GeneratedColumn<bool> get deleted =>
$composableBuilder(column: $table.deleted, builder: (column) => column);
GeneratedColumn<int> get playCount =>
$composableBuilder(column: $table.playCount, builder: (column) => column);
Expression<T> playlistSongsRefs<T extends Object>(
Expression<T> Function($$PlaylistSongsTableAnnotationComposer a) f,
) {
@@ -2656,6 +2720,7 @@ class $$SongsTableTableManager
Value<int> dateAddedMs = const Value.absent(),
Value<int> updatedAtMs = const Value.absent(),
Value<bool> deleted = const Value.absent(),
Value<int> playCount = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => SongsCompanion(
id: id,
@@ -2668,6 +2733,7 @@ class $$SongsTableTableManager
dateAddedMs: dateAddedMs,
updatedAtMs: updatedAtMs,
deleted: deleted,
playCount: playCount,
rowid: rowid,
),
createCompanionCallback:
@@ -2682,6 +2748,7 @@ class $$SongsTableTableManager
required int dateAddedMs,
required int updatedAtMs,
Value<bool> deleted = const Value.absent(),
Value<int> playCount = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => SongsCompanion.insert(
id: id,
@@ -2694,6 +2761,7 @@ class $$SongsTableTableManager
dateAddedMs: dateAddedMs,
updatedAtMs: updatedAtMs,
deleted: deleted,
playCount: playCount,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../shared/sort_store.dart';
import '../shared/sortable_song_list.dart';
import 'database.dart';
/// Favoriten-Tab: Shuffle-Wiedergabe und dieselbe Sortierung wie "Meine Musik",
/// nur auf den favorisierten Songs.
class FavoritesScreen extends StatelessWidget {
const FavoritesScreen({super.key});
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return SafeArea(
bottom: false,
child: Column(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('Favoriten',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
),
),
Expanded(
child: StreamBuilder<List<Song>>(
stream: db.watchFavorites(),
builder: (context, snapshot) {
final songs = snapshot.data ?? const <Song>[];
return SortableSongList(
songs: songs,
storeKey: SortStore.favoriten,
empty: const _Empty(),
);
},
),
),
],
),
);
}
}
class _Empty extends StatelessWidget {
const _Empty();
@override
Widget build(BuildContext context) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.favorite_border, size: 64, color: Colors.white24),
SizedBox(height: 12),
Text('Noch keine Favoriten', style: TextStyle(color: Colors.white54)),
SizedBox(height: 6),
Text('Tippe in einer Liederliste auf das Herz.',
style: TextStyle(color: Colors.white30, fontSize: 12)),
],
),
);
}
}
+359
View File
@@ -0,0 +1,359 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../playlists/playlists_screen.dart';
import '../settings/settings_screen.dart';
import '../shared/cover.dart';
import '../shared/sort_store.dart';
import '../shared/sortable_song_list.dart';
import '../shared/theme.dart';
import 'album_list.dart';
import 'artist_list.dart';
import 'database.dart';
import 'library_service.dart';
import 'permissions.dart';
import 'song_list.dart';
/// Startbildschirm: Kopfzeile mit Einstellungen/Suche/Musikerkennung,
/// Schnellzugriffe, darunter Shuffle + sortierbare Liederliste.
/// Bewusst ohne AppBar — die Kopfzeile ist Teil des Inhalts.
class MyMusicScreen extends StatelessWidget {
const MyMusicScreen({super.key, this.onSearchTap, this.onFavoritesTap});
/// Wechselt zum Suche-Tab; die Suche ist ein eigener Tab, kein eigener Screen.
final VoidCallback? onSearchTap;
/// Wechselt zum Favoriten-Tab.
final VoidCallback? onFavoritesTap;
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
final lib = context.watch<LibraryService>();
return SafeArea(
bottom: false,
child: Column(
children: [
_Header(onSearchTap: onSearchTap),
if (lib.permissionDenied)
MaterialBanner(
backgroundColor: Colors.red.shade900,
content: const Text('Zugriff auf Musik verweigert. '
'Bitte in den Einstellungen erlauben.'),
actions: [
TextButton(
onPressed: openMusicPermissionSettings,
child: const Text('Einstellungen'),
),
],
),
if (lib.scanError != null)
MaterialBanner(
backgroundColor: Colors.red.shade900,
content: Text(lib.scanError!),
actions: [
TextButton(
onPressed: lib.dismissScanError,
child: const Text('Verwerfen'),
),
],
),
if (lib.scanning)
Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
const LinearProgressIndicator(),
const SizedBox(height: 6),
Text(
'Scanne … ${lib.scanDone}'
'${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}',
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
_QuickAccessRow(onFavoritesTap: onFavoritesTap),
Expanded(
child: StreamBuilder<List<Song>>(
stream: db.watchSongs(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: Colors.white54)),
);
}
final songs = snapshot.data ?? const <Song>[];
return SortableSongList(
songs: songs,
storeKey: SortStore.meineMusik,
empty: lib.scanning ? const SizedBox.shrink() : const _Empty(),
);
},
),
),
],
),
);
}
}
/// Einstellungen · Suchfeld · Musikerkennung.
class _Header extends StatelessWidget {
const _Header({this.onSearchTap});
final VoidCallback? onSearchTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Row(
children: [
IconButton(
tooltip: 'Einstellungen',
icon: const Icon(Icons.tune),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
),
),
Expanded(
child: InkWell(
onTap: onSearchTap,
borderRadius: BorderRadius.circular(24),
child: Container(
height: 44,
decoration: BoxDecoration(
color: MeloTheme.surfaceHigh,
borderRadius: BorderRadius.circular(24),
),
child: const Row(
children: [
SizedBox(width: 14),
Icon(Icons.search, color: Colors.white54, size: 20),
SizedBox(width: 10),
Text(
'Titel, Künstler und Alben suchen',
style: TextStyle(color: Colors.white54, fontSize: 14),
),
],
),
),
),
),
IconButton(
tooltip: 'Musik erkennen',
icon: const Icon(Icons.help_outline),
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Musikerkennung kommt später')),
),
),
],
),
);
}
}
/// Waagerecht scrollende Schnellzugriffe (Favoriten, Wiedergabelisten, …).
class _QuickAccessRow extends StatelessWidget {
const _QuickAccessRow({this.onFavoritesTap});
final VoidCallback? onFavoritesTap;
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return SizedBox(
height: 104,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
StreamBuilder<List<Song>>(
stream: db.watchFavorites(),
builder: (context, snapshot) {
final favorites = snapshot.data ?? const <Song>[];
return _QuickCard(
icon: Icons.favorite,
label: 'Favoriten',
subtitle: '${favorites.length} Songs',
coverPath: favorites.isEmpty ? null : favorites.first.coverPath,
onTap: onFavoritesTap,
);
},
),
StreamBuilder<List<Playlist>>(
stream: db.watchPlaylists(),
builder: (context, snapshot) {
final playlists = snapshot.data ?? const <Playlist>[];
return _QuickCard(
icon: Icons.queue_music,
label: 'Wiedergabelisten',
subtitle: '${playlists.length} Listen',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const PlaylistsScreen()),
),
);
},
),
StreamBuilder<List<Song>>(
stream: db.watchRecent(limit: 50),
builder: (context, snapshot) {
final recent = snapshot.data ?? const <Song>[];
return _QuickCard(
icon: Icons.schedule,
label: 'Zuletzt',
subtitle: 'Neu hinzugefügt',
coverPath: recent.isEmpty ? null : recent.first.coverPath,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const RecentlyAddedScreen()),
),
);
},
),
_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()),
),
),
],
),
);
}
}
class _QuickCard extends StatelessWidget {
const _QuickCard({
required this.icon,
required this.label,
this.subtitle,
this.coverPath,
this.onTap,
});
final IconData icon;
final String label;
final String? subtitle;
final String? coverPath;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: SizedBox(
width: 150,
child: Material(
color: MeloTheme.surfaceHigh,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Stack(
fit: StackFit.expand,
children: [
if (coverPath != null)
Opacity(
opacity: 0.35,
child: CoverImage(
artUri: Uri.file(coverPath!),
size: 150,
radius: 0,
),
),
Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(icon, size: 22, color: Colors.white),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 14)),
if (subtitle != null)
Text(subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white54, fontSize: 11)),
],
),
],
),
),
],
),
),
),
),
);
}
}
/// Leerer Zustand mit direktem Weg zum Ordner-Scan.
class _Empty extends StatelessWidget {
const _Empty();
@override
Widget build(BuildContext context) {
final lib = context.read<LibraryService>();
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.library_music, size: 64, color: Colors.white24),
const SizedBox(height: 12),
const Text('Noch keine Musik',
style: TextStyle(color: Colors.white54)),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.create_new_folder_outlined),
label: Text(addMusicLabel),
onPressed: lib.pickFolderAndScan,
),
],
),
);
}
}
/// Vollbild-Liste der zuletzt hinzugefügten Songs (Schnellzugriff "Zuletzt").
class RecentlyAddedScreen extends StatelessWidget {
const RecentlyAddedScreen({super.key});
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return Scaffold(
appBar: AppBar(title: const Text('Zuletzt hinzugefügt')),
body: StreamBuilder<List<Song>>(
stream: db.watchRecent(limit: 100),
builder: (context, snapshot) {
final songs = snapshot.data ?? const <Song>[];
if (songs.isEmpty) {
return const Center(
child: Text('Noch nichts hinzugefügt',
style: TextStyle(color: Colors.white54)),
);
}
return SongList(songs);
},
),
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'database.dart';
/// Sortier-Kriterien der Liederlisten (Meine Musik + Favoriten).
enum SortMode { dateAdded, name, playCount }
extension SortModeLabel on SortMode {
String get label => switch (this) {
SortMode.dateAdded => 'Zuletzt hinzugefügt',
SortMode.name => 'Name (AZ#)',
SortMode.playCount => 'Wie oft abgespielt',
};
}
/// Richtung, in der ein Modus üblicherweise gelesen wird: neueste bzw.
/// meistgespielte Songs zuerst, Namen dagegen von A nach Z.
bool defaultAscending(SortMode mode) => mode == SortMode.name;
/// Sortiert eine Kopie von [songs]. Bei gleichem Sortierwert entscheidet der
/// Titel, damit die Reihenfolge zwischen Aufrufen stabil bleibt.
List<Song> sortSongs(List<Song> songs, SortMode mode, {required bool ascending}) {
final sorted = [...songs];
sorted.sort((a, b) {
final cmp = switch (mode) {
SortMode.dateAdded => a.dateAddedMs.compareTo(b.dateAddedMs),
SortMode.playCount => a.playCount.compareTo(b.playCount),
SortMode.name => _compareNames(a.title, b.title),
};
if (cmp != 0) return ascending ? cmp : -cmp;
// Tiebreak immer aufsteigend nach Titel — sonst wäre die Reihenfolge
// gleichwertiger Songs beim Umschalten der Richtung willkürlich.
return _compareNames(a.title, b.title);
});
return sorted;
}
/// AZ zuerst, alles was nicht mit einem Buchstaben beginnt (Ziffern,
/// Sonderzeichen) landet unter "#" am Ende.
int _compareNames(String a, String b) {
final keyA = _sortKey(a);
final keyB = _sortKey(b);
final letterA = _startsWithLetter(keyA);
final letterB = _startsWithLetter(keyB);
if (letterA != letterB) return letterA ? -1 : 1;
return keyA.compareTo(keyB);
}
final _letter = RegExp(r'^\p{L}', unicode: true);
bool _startsWithLetter(String s) => _letter.hasMatch(s);
/// Kleinschreibung + Umlaute auf ihren Grundbuchstaben, damit "Ärger"
/// zwischen "Anfang" und "Berg" steht und nicht hinter "Zug".
String _sortKey(String title) {
return title
.trim()
.toLowerCase()
.replaceAll('ä', 'a')
.replaceAll('ö', 'o')
.replaceAll('ü', 'u')
.replaceAll('ß', 'ss');
}
+21 -20
View File
@@ -2,18 +2,17 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'downloads/downloads_screen.dart';
import 'library/database.dart';
import 'library/library_screen.dart';
import 'library/favorites_screen.dart';
import 'library/library_service.dart';
import 'library/my_music_screen.dart';
import 'library/playlist_service.dart';
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 'services/logger_service.dart';
import 'services/offline_mode.dart';
import 'settings/settings_screen.dart';
import 'shared/theme.dart';
late final MeloAudioHandler _handler;
@@ -75,35 +74,37 @@ class HomeShell extends StatefulWidget {
class _HomeShellState extends State<HomeShell> {
int _index = 0;
static const _tabs = <Widget>[
NowPlayingScreen(),
LibraryScreen(),
PlaylistsScreen(),
SearchScreen(),
SettingsScreen(),
];
void _goTo(int index) => setState(() => _index = index);
@override
Widget build(BuildContext context) {
final tabs = <Widget>[
MyMusicScreen(
onSearchTap: () => _goTo(1),
onFavoritesTap: () => _goTo(2),
),
const SearchScreen(),
const FavoritesScreen(),
const DownloadsScreen(),
];
return Scaffold(
body: Column(
children: [
Expanded(child: IndexedStack(index: _index, children: _tabs)),
if (_index != 0) const MiniPlayer(),
Expanded(child: IndexedStack(index: _index, children: tabs)),
const MiniPlayer(),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _index,
onTap: (i) => setState(() => _index = i),
onTap: _goTo,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.play_circle_outline), label: 'Player'),
BottomNavigationBarItem(
icon: Icon(Icons.library_music), label: 'Bibliothek'),
BottomNavigationBarItem(
icon: Icon(Icons.playlist_play), label: 'Playlisten'),
icon: Icon(Icons.headphones), label: 'Meine Musik'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suche'),
BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Settings'),
BottomNavigationBarItem(
icon: Icon(Icons.favorite_border), label: 'Favoriten'),
BottomNavigationBarItem(
icon: Icon(Icons.download_outlined), label: 'Download'),
],
),
);
+15
View File
@@ -20,6 +20,12 @@ bool shouldResumeAt(int lastPositionMs, Duration? trackDuration) {
return true;
}
/// Entscheidet, ob für den Titel an [newIndex] eine Wiedergabe gezählt wird.
/// Gezählt wird jeder Titelwechsel; eine Wiederholung desselben Titels
/// (LoopMode.one) zählt bewusst nicht erneut.
bool shouldCountPlay(int? newIndex, int? lastCountedIndex) =>
newIndex != null && newIndex != lastCountedIndex;
/// Kern der Wiedergabe: kapselt just_audio hinter audio_service,
/// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren.
class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
@@ -29,6 +35,7 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
late final Timer _positionRecordTimer;
late final CacheManager _cache;
final NavidromeService _nav = NavidromeService();
int? _lastCountedIndex;
MeloAudioHandler({required this.db}) {
_cache = CacheManager();
@@ -56,6 +63,11 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
final q = queue.value;
if (index != null && index < q.length) {
mediaItem.add(q[index].copyWith(duration: _player.duration));
if (shouldCountPlay(index, _lastCountedIndex)) {
_lastCountedIndex = index;
final songId = q[index].extras?['songId'] as String?;
if (songId != null) db.incrementPlayCount(songId);
}
}
});
@@ -72,6 +84,9 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
/// Auto-Caching: Streame von Server + speichere lokal gleichzeitig.
Future<void> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
// Neue Warteschlange: der erste Titel soll wieder zählen, auch wenn er
// denselben Index wie der zuletzt gezählte hat.
_lastCountedIndex = null;
await _nav.ladeGespeicherteZugangsdaten();
final sources = <AudioSource>[];
+25
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../library/database.dart';
import '../library/library_service.dart';
import '../library/permissions.dart';
import '../library/playlist_service.dart';
import '../services/cache_manager.dart';
@@ -58,6 +59,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
title:
Text('${formatTotalDuration(songs)} Gesamtspieldauer'),
),
Consumer<LibraryService>(
builder: (context, lib, _) => Column(
children: [
ListTile(
leading: const Icon(Icons.create_new_folder_outlined),
title: Text(addMusicLabel),
subtitle: const Text(
'Musikordner auswählen und einlesen'),
enabled: !lib.scanning,
onTap: lib.pickFolderAndScan,
),
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Erneut scannen'),
subtitle: lib.scanning
? Text('Scanne … ${lib.scanDone}'
'${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}')
: const Text('Bekannte Ordner neu einlesen'),
enabled: !lib.scanning,
onTap: lib.rescan,
),
],
),
),
],
);
},
+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(