Kategorien (mehrere je Song), Metadaten-Ansicht mit Expertenmodus

- DB-Schema 4: Tabelle song_categories + Spalte categories_edited
- Kategorien kommen aus dem Genre-Tag (Trennung an ; , | /), manuell
  bearbeitbar; von Hand gesetzte Kategorien ueberlebt der naechste Scan
- Songzeile zeigt "Kuenstler | Kategorie1 - Kategorie2"
- Einstellung "Gleiche Kategorie = gleiches Coverbild" (Standard an)
- Metadaten-Sheet mit ausklappbarem Expertenmodus
- Neu: categories.dart, category_service.dart, song_detail_sheet.dart,
  app_settings.dart

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:29:45 +02:00
co-authored by Claude Opus 5
parent 2667613a2e
commit 7aa98864aa
22 changed files with 1792 additions and 10 deletions
+35
View File
@@ -5,6 +5,41 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [Unreleased]
### 🏷️ Kategorien, Metadaten-Ansicht und Expertenmodus (2026-08-20)
-**Was:** Jeder Song kann jetzt **mehreren Kategorien** angehören. Sie
stehen in der Songzeile hinter dem Künstler ("Neoni | Trap cool · Chill") —
genau wie in deinem Entwurf.
- 📥 **Woher sie kommen:** Beim Scan aus dem **Genre-Tag** der Datei. Mehrere
Werte in einem Tag werden automatisch getrennt (`;` `,` `|` `/`), doppelte
fallen weg. Über **⋮ → Metadaten → Bearbeiten** kannst du Kategorien selbst
hinzufügen oder entfernen; bereits vergebene Kategorien werden als
Vorschläge angeboten.
- 🔒 **Von Hand schlägt Automatik:** Sobald du die Kategorien eines Songs
selbst änderst, überschreibt ein späterer Scan sie **nicht** mehr.
- 🖼️ **Gleiche Kategorie = gleiches Coverbild:** Neue Einstellung unter
*Einstellungen → Darstellung* (standardmäßig **an**, jederzeit abschaltbar).
Ist sie an, zeigen alle Songs einer Kategorie das Bild der **ersten**
Kategorie; hat die Kategorie kein Bild, bleibt das eigene Cover. Im
Metadaten-Fenster ist die bildgebende Kategorie mit einem Bild-Symbol
markiert.
- 🔬 **Expertenmodus:** Im Metadaten-Fenster ausklappbar — Titel, Künstler,
Album, Dauer, Wiedergaben, Hinzugefügt-Datum, Herkunft der Kategorien,
Format, Dateigröße und Pfad.
- 🔧 **Details:**
- DB-Schema 4: neue Tabelle `song_categories` (Song ↔ Kategorie mit
Reihenfolge) und Spalte `categories_edited`. Bestehende Bibliotheken
werden automatisch migriert.
- Neu: `library/categories.dart` (reine Logik: Tag zerlegen, Cover je
Kategorie ermitteln — 12 Tests), `library/category_service.dart`
(hält Kategorien + Cover-Zuordnung einmal zentral, 3 Tests),
`library/song_detail_sheet.dart`, `settings/app_settings.dart`.
- Beide Scanner (Android-MediaStore und Ordner-Scan) schreiben die
Kategorien mit.
- Das Drei-Punkte-Menü der Songzeile ersetzt den bisherigen
Playlist-Knopf und enthält "Metadaten" und "Zu Wiedergabeliste
hinzufügen".
-**Getestet:** 129 Tests grün, `flutter analyze` ohne Befund.
### 🎨 Neue Melo-Oberfläche: 4 Tabs + Sortierung (2026-08-20) ### 🎨 Neue Melo-Oberfläche: 4 Tabs + Sortierung (2026-08-20)
-**Was:** Die App hat jetzt die von dir gewünschte Struktur mit -**Was:** Die App hat jetzt die von dir gewünschte Struktur mit
**4 Tabs: Meine Musik · Suche · Favoriten · Download**. "Meine Musik" ist **4 Tabs: Meine Musik · Suche · Favoriten · Download**. "Meine Musik" ist
+9
View File
@@ -6,6 +6,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'categories.dart';
import 'database.dart'; import 'database.dart';
const _uuid = Uuid(); const _uuid = Uuid();
@@ -34,6 +35,8 @@ Future<int> scanAndroidMediaStore(
final now = DateTime.now().millisecondsSinceEpoch; final now = DateTime.now().millisecondsSinceEpoch;
final companions = <SongsCompanion>[]; final companions = <SongsCompanion>[];
// Siehe scan_service.dart: erst Songs, dann Kategorien.
final categories = <String, List<String>>{};
var done = 0; var done = 0;
if (songs.isNotEmpty) onProgress?.call(0, songs.length); if (songs.isNotEmpty) onProgress?.call(0, songs.length);
@@ -69,6 +72,9 @@ Future<int> scanAndroidMediaStore(
updatedAtMs: now, updatedAtMs: now,
deleted: const Value(false), deleted: const Value(false),
)); ));
if (prev?.categoriesEdited != true) {
categories[id] = parseCategories(s.genre);
}
++done; ++done;
if (done % 50 == 0 || done == songs.length) { if (done % 50 == 0 || done == songs.length) {
onProgress?.call(done, songs.length); onProgress?.call(done, songs.length);
@@ -76,6 +82,9 @@ Future<int> scanAndroidMediaStore(
} }
await db.upsertSongs(companions); await db.upsertSongs(companions);
for (final entry in categories.entries) {
await db.setCategories(entry.key, entry.value);
}
if (companions.isNotEmpty) { if (companions.isNotEmpty) {
await db.markMissing(now); await db.markMissing(now);
} }
+61
View File
@@ -0,0 +1,61 @@
import 'database.dart';
/// Trennzeichen, mit denen Genre-Tags mehrere Werte in einem Feld ablegen.
final _separators = RegExp(r'[;,|/\n]');
/// Zerlegt einen Genre-Tag in einzelne Kategorien. Doppelte Einträge fallen
/// weg (ohne Rücksicht auf Groß-/Kleinschreibung), die erste Schreibweise
/// gewinnt.
List<String> parseCategories(String? raw) {
if (raw == null) return const [];
return _dedupe(raw.split(_separators).map((e) => e.trim()));
}
/// Wie [parseCategories], aber für Tag-Formate, die bereits mehrere Felder
/// liefern (z. B. ID3v2 mit mehreren GENRE-Frames).
List<String> parseCategoryList(Iterable<String> raw) {
return _dedupe(raw.expand((e) => e.split(_separators)).map((e) => e.trim()));
}
List<String> _dedupe(Iterable<String> candidates) {
final seen = <String>{};
final result = <String>[];
for (final candidate in candidates) {
if (candidate.isEmpty) continue;
if (seen.add(candidate.toLowerCase())) result.add(candidate);
}
return result;
}
/// Ermittelt je Kategorie ein Coverbild: das erste vorhandene Cover unter den
/// Songs der Kategorie, nach Titel geordnet — damit dieselbe Bibliothek immer
/// dasselbe Bild ergibt. Kategorien ohne jedes Cover fehlen im Ergebnis.
Map<String, String> categoryCovers(
List<Song> songs,
Map<String, List<String>> categoriesBySong,
) {
final ordered = [...songs]
..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
final covers = <String, String>{};
for (final song in ordered) {
final cover = song.coverPath;
if (cover == null) continue;
for (final category in categoriesBySong[song.id] ?? const <String>[]) {
covers.putIfAbsent(category, () => cover);
}
}
return covers;
}
/// Das anzuzeigende Cover eines Songs: bei aktiver Einstellung "Gleiche
/// Kategorie = gleiches Coverbild" das Bild seiner **ersten** Kategorie,
/// sonst (oder wenn die Kategorie kein Bild hat) sein eigenes.
String? effectiveCover(
Song song,
List<String> categories,
Map<String, String> covers, {
required bool groupByCategory,
}) {
if (!groupByCategory || categories.isEmpty) return song.coverPath;
return covers[categories.first] ?? song.coverPath;
}
+57
View File
@@ -0,0 +1,57 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'categories.dart';
import 'database.dart';
/// Hält die Kategorien aller Songs und das daraus abgeleitete Cover je
/// Kategorie an einer Stelle — sonst müsste jede Liederliste beide Streams
/// selbst beobachten und die Cover-Zuordnung könnte je Liste abweichen.
class CategoryService extends ChangeNotifier {
CategoryService(this._db) {
_songs = _db.watchSongs().listen((songs) {
_allSongs = songs;
_recompute();
});
_categories = _db.watchCategoriesBySong().listen((byId) {
_byId = byId;
_recompute();
});
}
final MeloDb _db;
late final StreamSubscription<List<Song>> _songs;
late final StreamSubscription<Map<String, List<String>>> _categories;
List<Song> _allSongs = const [];
Map<String, List<String>> _byId = const {};
Map<String, String> _covers = const {};
/// Kategorien eines Songs in gespeicherter Reihenfolge.
List<String> of(String songId) => _byId[songId] ?? const [];
/// Das anzuzeigende Cover eines Songs unter Berücksichtigung der
/// Einstellung "Gleiche Kategorie = gleiches Coverbild".
String? coverFor(Song song, {required bool groupByCategory}) {
return effectiveCover(song, of(song.id), _covers,
groupByCategory: groupByCategory);
}
Future<void> setCategories(String songId, List<String> names) =>
_db.setCategories(songId, names, byUser: true);
Future<List<String>> allNames() => _db.allCategoryNames();
void _recompute() {
_covers = categoryCovers(_allSongs, _byId);
notifyListeners();
}
@override
void dispose() {
_songs.cancel();
_categories.cancel();
super.dispose();
}
}
+86 -2
View File
@@ -26,6 +26,10 @@ class Songs extends Table {
/// Sortierung "Wie oft abgespielt". /// Sortierung "Wie oft abgespielt".
IntColumn get playCount => integer().withDefault(const Constant(0))(); IntColumn get playCount => integer().withDefault(const Constant(0))();
/// Sobald die Kategorien eines Songs von Hand geändert wurden, überschreibt
/// ein erneuter Scan sie nicht mehr mit dem Genre-Tag der Datei.
BoolColumn get categoriesEdited => boolean().withDefault(const Constant(false))();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@@ -65,6 +69,17 @@ class PlaylistSongs extends Table {
Set<Column> get primaryKey => {playlistId, songId}; Set<Column> get primaryKey => {playlistId, songId};
} }
/// Kategorien eines Songs — ein Song kann mehreren angehören. [position]
/// hält die Reihenfolge fest; die erste Kategorie bestimmt das Cover.
class SongCategories extends Table {
TextColumn get songId => text().references(Songs, #id)();
TextColumn get name => text()();
IntColumn get position => integer()();
@override
Set<Column> get primaryKey => {songId, name};
}
/// Favorisierte Songs. /// Favorisierte Songs.
class Favorites extends Table { class Favorites extends Table {
TextColumn get songId => text().references(Songs, #id)(); TextColumn get songId => text().references(Songs, #id)();
@@ -84,12 +99,20 @@ class PlaybackHistory extends Table {
Set<Column> get primaryKey => {songId, playedAtMs}; Set<Column> get primaryKey => {songId, playedAtMs};
} }
@DriftDatabase(tables: [Songs, Folders, Playlists, PlaylistSongs, Favorites, PlaybackHistory]) @DriftDatabase(tables: [
Songs,
Folders,
Playlists,
PlaylistSongs,
Favorites,
PlaybackHistory,
SongCategories,
])
class MeloDb extends _$MeloDb { class MeloDb extends _$MeloDb {
MeloDb([QueryExecutor? executor]) : super(executor ?? _open()); MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
@override @override
int get schemaVersion => 3; int get schemaVersion => 4;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -104,6 +127,10 @@ class MeloDb extends _$MeloDb {
if (from < 3) { if (from < 3) {
await m.addColumn(songs, songs.playCount); await m.addColumn(songs, songs.playCount);
} }
if (from < 4) {
await m.addColumn(songs, songs.categoriesEdited);
await m.createTable(songCategories);
}
}, },
); );
@@ -297,6 +324,63 @@ class MeloDb extends _$MeloDb {
return query.watch().map((rows) => rows.map((r) => r.readTable(songs)).toList()); return query.watch().map((rows) => rows.map((r) => r.readTable(songs)).toList());
} }
// === Kategorien ===
/// Alle Kategorien-Zuordnungen, nach Song gebündelt und in gespeicherter
/// Reihenfolge — die UI braucht sie immer für die ganze sichtbare Liste.
Stream<Map<String, List<String>>> watchCategoriesBySong() {
return (select(songCategories)
..orderBy([(c) => OrderingTerm(expression: c.position)]))
.watch()
.map((rows) {
final grouped = <String, List<String>>{};
for (final row in rows) {
grouped.putIfAbsent(row.songId, () => []).add(row.name);
}
return grouped;
});
}
Future<List<String>> categoriesOf(String songId) async {
final rows = await (select(songCategories)
..where((c) => c.songId.equals(songId))
..orderBy([(c) => OrderingTerm(expression: c.position)]))
.get();
return rows.map((r) => r.name).toList();
}
/// Ersetzt die Kategorien eines Songs. [byUser] markiert den Song als von
/// Hand bearbeitet, sodass der nächste Scan ihn in Ruhe lässt.
Future<void> setCategories(
String songId,
List<String> names, {
bool byUser = false,
}) async {
await transaction(() async {
await (delete(songCategories)..where((c) => c.songId.equals(songId))).go();
for (var i = 0; i < names.length; i++) {
await into(songCategories).insert(SongCategoriesCompanion.insert(
songId: songId,
name: names[i],
position: i,
));
}
if (byUser) {
await (update(songs)..where((s) => s.id.equals(songId)))
.write(const SongsCompanion(categoriesEdited: Value(true)));
}
});
}
/// Alle vergebenen Kategorien, alphabetisch — für Vorschläge beim Bearbeiten.
Future<List<String>> allCategoryNames() async {
final rows = await (selectOnly(songCategories, distinct: true)
..addColumns([songCategories.name]))
.get();
final names = rows.map((r) => r.read(songCategories.name)!).toList()
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
return names;
}
// === Playback History === // === Playback History ===
/// Zählt eine Wiedergabe. Wird beim Start eines Tracks aufgerufen, nicht beim /// Zählt eine Wiedergabe. Wird beim Start eines Tracks aufgerufen, nicht beim
/// periodischen Speichern der Position — sonst würde der Zähler hochlaufen, /// periodischen Speichern der Position — sonst würde der Zähler hochlaufen,
File diff suppressed because it is too large Load Diff
+10
View File
@@ -6,6 +6,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'categories.dart';
import 'database.dart'; import 'database.dart';
const _audioExt = { const _audioExt = {
@@ -45,6 +46,9 @@ Future<int> scanFolders(
final now = DateTime.now().millisecondsSinceEpoch; final now = DateTime.now().millisecondsSinceEpoch;
final companions = <SongsCompanion>[]; final companions = <SongsCompanion>[];
// Kategorien werden erst nach dem Upsert geschrieben — vorher gibt es die
// Songzeile noch nicht, auf die sie verweisen.
final categories = <String, List<String>>{};
var done = 0; var done = 0;
if (files.isNotEmpty) onProgress?.call(0, files.length); if (files.isNotEmpty) onProgress?.call(0, files.length);
@@ -89,6 +93,9 @@ Future<int> scanFolders(
updatedAtMs: now, updatedAtMs: now,
deleted: const Value(false), deleted: const Value(false),
)); ));
if (prev?.categoriesEdited != true) {
categories[id] = parseCategoryList(meta?.genres ?? const []);
}
++done; ++done;
if (done % 50 == 0 || done == files.length) { if (done % 50 == 0 || done == files.length) {
onProgress?.call(done, files.length); onProgress?.call(done, files.length);
@@ -96,6 +103,9 @@ Future<int> scanFolders(
} }
await db.upsertSongs(companions); await db.upsertSongs(companions);
for (final entry in categories.entries) {
await db.setCategories(entry.key, entry.value);
}
if (companions.isNotEmpty) { if (companions.isNotEmpty) {
await db.markMissing(now); await db.markMissing(now);
} }
+327
View File
@@ -0,0 +1,327 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../settings/app_settings.dart';
import '../shared/cover.dart';
import '../shared/theme.dart';
import 'category_service.dart';
import 'database.dart';
/// Metadaten eines Songs: Cover, Titel, Künstler und Kategorien (bearbeitbar),
/// darunter der ausklappbare Expertenmodus mit allen weiteren Angaben.
class SongDetailSheet extends StatelessWidget {
const SongDetailSheet({super.key, required this.song});
final Song song;
static Future<void> show(BuildContext context, Song song) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: MeloTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => SongDetailSheet(song: song),
);
}
@override
Widget build(BuildContext context) {
final categories = context.watch<CategoryService>();
final settings = context.watch<AppSettings>();
final names = categories.of(song.id);
final cover =
categories.coverFor(song, groupByCategory: settings.groupCoversByCategory);
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 88,
radius: 10,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(song.title,
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(song.artist ?? 'Unbekannter Künstler',
style: const TextStyle(color: Colors.white70)),
if (song.album != null) ...[
const SizedBox(height: 2),
Text(song.album!,
style: const TextStyle(
color: Colors.white38, fontSize: 12)),
],
],
),
),
],
),
const SizedBox(height: 20),
Row(
children: [
const Text('Kategorien',
style: TextStyle(fontWeight: FontWeight.w600)),
const Spacer(),
TextButton.icon(
icon: const Icon(Icons.edit, size: 16),
label: const Text('Bearbeiten'),
onPressed: () => _editCategories(context, names),
),
],
),
if (names.isEmpty)
const Text('Keine Kategorie',
style: TextStyle(color: Colors.white38, fontSize: 13))
else
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (var i = 0; i < names.length; i++)
Chip(
label: Text(names[i]),
backgroundColor: MeloTheme.surfaceHigh,
side: BorderSide.none,
// Die erste Kategorie liefert das Coverbild.
avatar: i == 0
? const Icon(Icons.image, size: 16, color: MeloTheme.red)
: null,
),
],
),
const SizedBox(height: 12),
_ExpertSection(song: song),
],
),
),
);
}
Future<void> _editCategories(BuildContext context, List<String> current) async {
final service = context.read<CategoryService>();
final suggestions = await service.allNames();
if (!context.mounted) return;
final result = await showDialog<List<String>>(
context: context,
builder: (_) => _CategoryEditor(current: current, suggestions: suggestions),
);
if (result == null) return;
await service.setCategories(song.id, result);
}
}
/// Ausklappbarer Bereich mit allen weiteren Metadaten.
class _ExpertSection extends StatelessWidget {
const _ExpertSection({required this.song});
final Song song;
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
title: const Text('Expertenmodus',
style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text('Weitere Metadaten',
style: TextStyle(color: Colors.white38, fontSize: 12)),
children: [
_Row('Titel', song.title),
_Row('Künstler', song.artist ?? ''),
_Row('Album', song.album ?? ''),
_Row('Dauer', _formatDuration(song.durationMs)),
_Row('Wiedergaben', '${song.playCount}'),
_Row('Hinzugefügt', _formatDate(song.dateAddedMs)),
_Row('Kategorien', song.categoriesEdited
? 'von Hand gesetzt (Scan überschreibt nicht)'
: 'aus dem Genre-Tag der Datei'),
_Row('Format', _extension(song.path)),
_Row('Dateigröße', _fileSize(song.path)),
_Row('Pfad', song.path),
],
),
);
}
}
class _Row extends StatelessWidget {
const _Row(this.label, this.value);
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(label,
style: const TextStyle(color: Colors.white38, fontSize: 13)),
),
Expanded(
child: Text(value, style: const TextStyle(fontSize: 13)),
),
],
),
);
}
}
String _formatDuration(int? ms) {
if (ms == null) return '';
final d = Duration(milliseconds: ms);
final minutes = d.inMinutes;
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
String _formatDate(int ms) {
final d = DateTime.fromMillisecondsSinceEpoch(ms);
String two(int v) => v.toString().padLeft(2, '0');
return '${two(d.day)}.${two(d.month)}.${d.year} ${two(d.hour)}:${two(d.minute)}';
}
String _extension(String path) {
final dot = path.lastIndexOf('.');
return dot == -1 ? '' : path.substring(dot + 1).toUpperCase();
}
String _fileSize(String path) {
try {
final bytes = File(path).lengthSync();
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(0)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} catch (_) {
return '';
}
}
/// Dialog zum Hinzufügen und Entfernen von Kategorien.
class _CategoryEditor extends StatefulWidget {
const _CategoryEditor({required this.current, required this.suggestions});
final List<String> current;
final List<String> suggestions;
@override
State<_CategoryEditor> createState() => _CategoryEditorState();
}
class _CategoryEditorState extends State<_CategoryEditor> {
late final List<String> _names = [...widget.current];
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _add(String raw) {
final name = raw.trim();
if (name.isEmpty) return;
if (_names.any((n) => n.toLowerCase() == name.toLowerCase())) return;
setState(() => _names.add(name));
_controller.clear();
}
@override
Widget build(BuildContext context) {
final offen = widget.suggestions
.where((s) => !_names.any((n) => n.toLowerCase() == s.toLowerCase()))
.toList();
return AlertDialog(
backgroundColor: MeloTheme.surface,
title: const Text('Kategorien'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Die erste Kategorie liefert das Coverbild.',
style: TextStyle(color: Colors.white38, fontSize: 12),
),
const SizedBox(height: 12),
if (_names.isEmpty)
const Text('Noch keine Kategorie',
style: TextStyle(color: Colors.white38, fontSize: 13))
else
Wrap(
spacing: 8,
children: [
for (final name in _names)
InputChip(
label: Text(name),
backgroundColor: MeloTheme.surfaceHigh,
side: BorderSide.none,
onDeleted: () => setState(() => _names.remove(name)),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Kategorie hinzufügen',
border: OutlineInputBorder(),
),
onSubmitted: _add,
),
if (offen.isNotEmpty) ...[
const SizedBox(height: 12),
const Text('Bereits vergeben',
style: TextStyle(color: Colors.white38, fontSize: 12)),
const SizedBox(height: 4),
Wrap(
spacing: 8,
children: [
for (final name in offen.take(12))
ActionChip(
label: Text(name),
backgroundColor: MeloTheme.surfaceHigh,
side: BorderSide.none,
onPressed: () => _add(name),
),
],
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () {
// Ein noch nicht bestätigter Text im Feld soll nicht verloren gehen.
_add(_controller.text);
Navigator.pop(context, _names);
},
child: const Text('Speichern'),
),
],
);
}
}
+53 -6
View File
@@ -3,10 +3,13 @@ import 'package:provider/provider.dart';
import '../player/audio_handler.dart'; import '../player/audio_handler.dart';
import '../playlists/create_playlist_dialog.dart'; import '../playlists/create_playlist_dialog.dart';
import '../settings/app_settings.dart';
import '../shared/cover.dart'; import '../shared/cover.dart';
import '../shared/favorite_button.dart'; import '../shared/favorite_button.dart';
import 'category_service.dart';
import 'database.dart'; import 'database.dart';
import 'playlist_service.dart'; import 'playlist_service.dart';
import 'song_detail_sheet.dart';
import 'song_media.dart'; import 'song_media.dart';
/// Scrollbare Songliste; Tippen spielt die ganze Liste ab dem Song ab. /// Scrollbare Songliste; Tippen spielt die ganze Liste ab dem Song ab.
@@ -71,30 +74,74 @@ class SongList extends StatelessWidget {
); );
} }
/// "Künstler | Kategorie1 · Kategorie2" — wie im UI-Entwurf.
static String _subtitle(Song song, List<String> categories) {
final artist = song.artist ?? 'Unbekannt';
if (categories.isEmpty) return artist;
return '$artist | ${categories.join(' · ')}';
}
/// Menü hinter dem Drei-Punkte-Symbol einer Songzeile.
Future<void> _showMenu(BuildContext context, Song song) async {
await showModalBottomSheet(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('Metadaten'),
onTap: () {
Navigator.pop(sheetContext);
SongDetailSheet.show(context, song);
},
),
ListTile(
leading: const Icon(Icons.playlist_add),
title: const Text('Zu Wiedergabeliste hinzufügen'),
onTap: () {
Navigator.pop(sheetContext);
_showAddToPlaylist(context, song);
},
),
],
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>(); final handler = context.read<MeloAudioHandler>();
final categories = context.watch<CategoryService>();
final settings = context.watch<AppSettings>();
return ListView.builder( return ListView.builder(
itemCount: songs.length, itemCount: songs.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final s = songs[i]; final s = songs[i];
final cover = categories.coverFor(s,
groupByCategory: settings.groupCoversByCategory);
return ListTile( return ListTile(
leading: CoverImage( leading: CoverImage(
artUri: s.coverPath != null ? Uri.file(s.coverPath!) : null, artUri: cover != null ? Uri.file(cover) : null,
size: 48, size: 48,
radius: 6, radius: 6,
), ),
title: Text(s.title, maxLines: 1, overflow: TextOverflow.ellipsis), title: Text(s.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(s.artist ?? 'Unbekannt', subtitle: Text(
maxLines: 1, overflow: TextOverflow.ellipsis), _subtitle(s, categories.of(s.id)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
FavoriteButton(songId: s.id), FavoriteButton(songId: s.id),
IconButton( IconButton(
tooltip: 'Zu Playlist hinzufügen', tooltip: 'Mehr',
icon: const Icon(Icons.playlist_add), icon: const Icon(Icons.more_vert),
onPressed: () => _showAddToPlaylist(context, s), onPressed: () => _showMenu(context, s),
), ),
], ],
), ),
+9
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'downloads/downloads_screen.dart'; import 'downloads/downloads_screen.dart';
import 'library/category_service.dart';
import 'library/database.dart'; import 'library/database.dart';
import 'library/favorites_screen.dart'; import 'library/favorites_screen.dart';
import 'library/library_service.dart'; import 'library/library_service.dart';
@@ -13,6 +14,7 @@ import 'player/audio_handler.dart';
import 'player/mini_player.dart'; import 'player/mini_player.dart';
import 'services/logger_service.dart'; import 'services/logger_service.dart';
import 'services/offline_mode.dart'; import 'services/offline_mode.dart';
import 'settings/app_settings.dart';
import 'shared/theme.dart'; import 'shared/theme.dart';
late final MeloAudioHandler _handler; late final MeloAudioHandler _handler;
@@ -20,6 +22,8 @@ late final MeloDb _db;
late final LibraryService _library; late final LibraryService _library;
late final PlaylistService _playlists; late final PlaylistService _playlists;
late final OfflineMode _offlineMode; late final OfflineMode _offlineMode;
late final CategoryService _categories;
late final AppSettings _settings;
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -29,6 +33,9 @@ Future<void> main() async {
_playlists = PlaylistService(_db); _playlists = PlaylistService(_db);
_offlineMode = OfflineMode(); _offlineMode = OfflineMode();
await _offlineMode.init(); await _offlineMode.init();
_categories = CategoryService(_db);
_settings = AppSettings();
await _settings.init();
_handler = await AudioService.init( _handler = await AudioService.init(
builder: () => MeloAudioHandler(db: _db), builder: () => MeloAudioHandler(db: _db),
config: const AudioServiceConfig( config: const AudioServiceConfig(
@@ -53,6 +60,8 @@ class MeloApp extends StatelessWidget {
ChangeNotifierProvider<LibraryService>.value(value: _library), ChangeNotifierProvider<LibraryService>.value(value: _library),
ChangeNotifierProvider<PlaylistService>.value(value: _playlists), ChangeNotifierProvider<PlaylistService>.value(value: _playlists),
ChangeNotifierProvider<OfflineMode>.value(value: _offlineMode), ChangeNotifierProvider<OfflineMode>.value(value: _offlineMode),
ChangeNotifierProvider<CategoryService>.value(value: _categories),
ChangeNotifierProvider<AppSettings>.value(value: _settings),
], ],
child: MaterialApp( child: MaterialApp(
title: 'Melo', title: 'Melo',
+42
View File
@@ -0,0 +1,42 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Nutzer-Einstellungen, die die ganze App betreffen. Werden beim Start
/// einmal geladen und bei jeder Änderung sofort gespeichert.
class AppSettings extends ChangeNotifier {
static const _keyGroupCovers = 'gleiche_kategorie_gleiches_cover';
static const _keyAutoPlayHeadphones = 'autoplay_kopfhoerer';
bool _groupCoversByCategory = true;
bool _autoPlayOnHeadphones = false;
/// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der
/// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild.
bool get groupCoversByCategory => _groupCoversByCategory;
/// Wiedergabe automatisch starten, sobald Kopfhörer verbunden werden.
bool get autoPlayOnHeadphones => _autoPlayOnHeadphones;
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
_groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true;
_autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false;
notifyListeners();
}
Future<void> setGroupCoversByCategory(bool value) async {
if (_groupCoversByCategory == value) return;
_groupCoversByCategory = value;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyGroupCovers, value);
}
Future<void> setAutoPlayOnHeadphones(bool value) async {
if (_autoPlayOnHeadphones == value) return;
_autoPlayOnHeadphones = value;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyAutoPlayHeadphones, value);
}
}
+13
View File
@@ -9,6 +9,7 @@ import '../services/cache_manager.dart';
import '../services/logger_service.dart'; import '../services/logger_service.dart';
import '../services/navidrome_service.dart'; import '../services/navidrome_service.dart';
import '../services/offline_mode.dart'; import '../services/offline_mode.dart';
import 'app_settings.dart';
import 'library_stats.dart'; import 'library_stats.dart';
/// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Navidrome, Über Melo. /// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Navidrome, Über Melo.
@@ -88,6 +89,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
const Divider(height: 1), const Divider(height: 1),
const _SectionLabel('Darstellung'),
Consumer<AppSettings>(
builder: (context, settings, _) => SwitchListTile(
secondary: const Icon(Icons.photo_library_outlined),
title: const Text('Gleiche Kategorie = gleiches Coverbild'),
subtitle: const Text(
'Songs einer Kategorie zeigen das Bild der ersten Kategorie'),
value: settings.groupCoversByCategory,
onChanged: settings.setGroupCoversByCategory,
),
),
const Divider(height: 1),
const _SectionLabel('Musikserver'), const _SectionLabel('Musikserver'),
ListTile( ListTile(
leading: const Icon(Icons.cloud_circle), leading: const Icon(Icons.cloud_circle),
+130
View File
@@ -0,0 +1,130 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/categories.dart';
import 'package:melo/library/database.dart';
Song _song(String id, {String? coverPath, String title = 'T'}) => Song(
id: id,
path: '/$id.mp3',
title: title,
dateAddedMs: 0,
updatedAtMs: 0,
deleted: false,
playCount: 0,
coverPath: coverPath,
categoriesEdited: false,
);
void main() {
group('parseCategories', () {
test('trennt an Semikolon, Komma, Schrägstrich und Pipe', () {
expect(parseCategories('Rock; Pop'), ['Rock', 'Pop']);
expect(parseCategories('Rock,Pop'), ['Rock', 'Pop']);
expect(parseCategories('Rock/Pop'), ['Rock', 'Pop']);
expect(parseCategories('Rock|Pop'), ['Rock', 'Pop']);
});
test('behält Leerzeichen innerhalb einer Kategorie', () {
expect(parseCategories('Hip Hop; Trap cool'), ['Hip Hop', 'Trap cool']);
});
test('entfernt Leereinträge und doppelte Kategorien', () {
expect(parseCategories('Rock;;rock; Pop ;Rock'), ['Rock', 'Pop']);
});
test('leerer oder fehlender Tag ergibt keine Kategorie', () {
expect(parseCategories(null), isEmpty);
expect(parseCategories(' '), isEmpty);
});
test('verarbeitet auch eine Liste aus mehreren Tag-Feldern', () {
expect(parseCategoryList(['Rock', 'Pop;Trap', 'rock']),
['Rock', 'Pop', 'Trap']);
});
});
group('categoryCovers', () {
test('nimmt je Kategorie das erste vorhandene Cover', () {
final songs = [
_song('1', title: 'A', coverPath: null),
_song('2', title: 'B', coverPath: '/cover_b.img'),
_song('3', title: 'C', coverPath: '/cover_c.img'),
];
final categories = {
'1': ['Rock'],
'2': ['Rock'],
'3': ['Pop'],
};
final covers = categoryCovers(songs, categories);
expect(covers['Rock'], '/cover_b.img');
expect(covers['Pop'], '/cover_c.img');
});
test('Kategorie ganz ohne Cover taucht nicht auf', () {
final songs = [_song('1', coverPath: null)];
final covers = categoryCovers(songs, {'1': ['Leer']});
expect(covers.containsKey('Leer'), isFalse);
});
test('ist unabhängig von der Reihenfolge der Eingabeliste', () {
final songs = [
_song('2', title: 'B', coverPath: '/cover_b.img'),
_song('1', title: 'A', coverPath: '/cover_a.img'),
];
final categories = {'1': ['Rock'], '2': ['Rock']};
expect(categoryCovers(songs, categories)['Rock'], '/cover_a.img');
});
});
group('effectiveCover', () {
final covers = {'Rock': '/rock.img', 'Pop': '/pop.img'};
test('nimmt das Cover der ersten Kategorie', () {
final cover = effectiveCover(
_song('1', coverPath: '/eigen.img'),
['Pop', 'Rock'],
covers,
groupByCategory: true,
);
expect(cover, '/pop.img');
});
test('fällt auf das eigene Cover zurück, wenn die Kategorie keins hat', () {
final cover = effectiveCover(
_song('1', coverPath: '/eigen.img'),
['Unbekannt'],
covers,
groupByCategory: true,
);
expect(cover, '/eigen.img');
});
test('abgeschaltet gilt immer das eigene Cover', () {
final cover = effectiveCover(
_song('1', coverPath: '/eigen.img'),
['Pop'],
covers,
groupByCategory: false,
);
expect(cover, '/eigen.img');
});
test('ohne Kategorien gilt das eigene Cover', () {
final cover = effectiveCover(
_song('1', coverPath: '/eigen.img'),
const [],
covers,
groupByCategory: true,
);
expect(cover, '/eigen.img');
});
});
}
+65
View File
@@ -0,0 +1,65 @@
import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
void main() {
late MeloDb db;
late CategoryService service;
setUp(() => db = MeloDb(NativeDatabase.memory()));
tearDown(() async {
service.dispose();
await db.close();
});
Future<void> insert(String id, {String? cover}) => db.upsertSongs([
SongsCompanion.insert(
id: id,
path: '/$id.mp3',
title: 'Song $id',
coverPath: Value(cover),
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
Future<void> settle() => Future<void>.delayed(const Duration(milliseconds: 50));
test('reicht die Kategorien eines Songs durch', () async {
await insert('1');
await db.setCategories('1', ['Rock', 'Pop']);
service = CategoryService(db);
await settle();
expect(service.of('1'), ['Rock', 'Pop']);
expect(service.of('unbekannt'), isEmpty);
});
test('Songs derselben Kategorie bekommen dasselbe Cover', () async {
await insert('1', cover: null);
await insert('2', cover: '/rock.img');
await db.setCategories('1', ['Rock']);
await db.setCategories('2', ['Rock']);
service = CategoryService(db);
await settle();
final ohneCover = (await db.watchSongs().first).firstWhere((s) => s.id == '1');
expect(service.coverFor(ohneCover, groupByCategory: true), '/rock.img');
expect(service.coverFor(ohneCover, groupByCategory: false), isNull);
});
test('von Hand gesetzte Kategorien markieren den Song als bearbeitet',
() async {
await insert('1');
service = CategoryService(db);
await settle();
await service.setCategories('1', ['Eigen']);
await settle();
expect(service.of('1'), ['Eigen']);
expect((await db.watchSongs().first).single.categoriesEdited, isTrue);
});
}
@@ -0,0 +1,69 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/database.dart';
void main() {
late MeloDb db;
setUp(() => db = MeloDb(NativeDatabase.memory()));
tearDown(() => db.close());
Future<void> insert(String id) => db.upsertSongs([
SongsCompanion.insert(
id: id,
path: '/$id.mp3',
title: 'Song $id',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
test('ein Song kann mehreren Kategorien angehören, Reihenfolge bleibt',
() async {
await insert('1');
await db.setCategories('1', ['Trap cool', 'Rock', 'Chill']);
expect(await db.categoriesOf('1'), ['Trap cool', 'Rock', 'Chill']);
});
test('setCategories ersetzt die bisherigen Kategorien', () async {
await insert('1');
await db.setCategories('1', ['Alt']);
await db.setCategories('1', ['Neu']);
expect(await db.categoriesOf('1'), ['Neu']);
});
test('byUser markiert den Song als von Hand bearbeitet', () async {
await insert('1');
await db.setCategories('1', ['Rock']);
expect((await db.watchSongs().first).single.categoriesEdited, isFalse);
await db.setCategories('1', ['Pop'], byUser: true);
expect((await db.watchSongs().first).single.categoriesEdited, isTrue);
});
test('watchCategoriesBySong bündelt nach Song', () async {
await insert('1');
await insert('2');
await db.setCategories('1', ['Rock', 'Pop']);
await db.setCategories('2', ['Jazz']);
final byId = await db.watchCategoriesBySong().first;
expect(byId['1'], ['Rock', 'Pop']);
expect(byId['2'], ['Jazz']);
});
test('allCategoryNames liefert jede Kategorie einmal, alphabetisch',
() async {
await insert('1');
await insert('2');
await db.setCategories('1', ['Rock', 'Ambient']);
await db.setCategories('2', ['Rock']);
expect(await db.allCategoryNames(), ['Ambient', 'Rock']);
});
}
+4
View File
@@ -2,12 +2,14 @@ import 'package:drift/drift.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart'; import 'package:melo/library/database.dart';
import 'package:melo/library/favorites_screen.dart'; import 'package:melo/library/favorites_screen.dart';
import 'package:melo/library/library_service.dart'; import 'package:melo/library/library_service.dart';
import 'package:melo/library/my_music_screen.dart'; import 'package:melo/library/my_music_screen.dart';
import 'package:melo/library/playlist_service.dart'; import 'package:melo/library/playlist_service.dart';
import 'package:melo/player/audio_handler.dart'; import 'package:melo/player/audio_handler.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -19,6 +21,8 @@ Widget _wrap(MeloDb db, LibraryService lib, PlaylistService playlists,
ChangeNotifierProvider<LibraryService>.value(value: lib), ChangeNotifierProvider<LibraryService>.value(value: lib),
ChangeNotifierProvider<PlaylistService>.value(value: playlists), ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler), Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
], ],
child: MaterialApp(home: Scaffold(body: child)), child: MaterialApp(home: Scaffold(body: child)),
); );
+1
View File
@@ -18,6 +18,7 @@ Song _song({
updatedAtMs: 0, updatedAtMs: 0,
deleted: false, deleted: false,
playCount: 0, playCount: 0,
categoriesEdited: false,
); );
} }
@@ -0,0 +1,90 @@
import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/library/song_list.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
Widget _wrap(MeloDb db, MeloAudioHandler handler) => MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<PlaylistService>(create: (_) => PlaylistService(db)),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
],
child: MaterialApp(
home: Scaffold(
body: StreamBuilder<List<Song>>(
stream: db.watchSongs(),
builder: (context, snapshot) => SongList(snapshot.data ?? const []),
),
),
),
);
Future<void> _insertRoses(MeloDb db) async {
await db.upsertSongs([
SongsCompanion.insert(
id: '1',
path: '/a.mp3',
title: 'Roses',
artist: const Value('Neoni'),
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
await db.setCategories('1', ['Trap cool', 'Chill']);
}
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets('Songzeile zeigt "Künstler | Kategorien"', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final handler = MeloAudioHandler(db: db);
await _insertRoses(db);
await tester.pumpWidget(_wrap(db, handler));
await tester.pumpAndSettle();
expect(find.text('Roses'), findsOneWidget);
expect(find.text('Neoni | Trap cool · Chill'), findsOneWidget);
handler.dispose();
await db.close();
});
testWidgets('Metadaten-Ansicht zeigt Kategorien und den Expertenmodus',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final handler = MeloAudioHandler(db: db);
await _insertRoses(db);
await tester.pumpWidget(_wrap(db, handler));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
await tester.tap(find.text('Metadaten'));
await tester.pumpAndSettle();
expect(find.text('Kategorien'), findsOneWidget);
expect(find.text('Trap cool'), findsWidgets);
expect(find.text('Expertenmodus'), findsOneWidget);
await tester.tap(find.text('Expertenmodus'));
await tester.pumpAndSettle();
expect(find.text('Wiedergaben'), findsOneWidget);
handler.dispose();
await db.close();
});
}
+1
View File
@@ -16,6 +16,7 @@ void main() {
updatedAtMs: 0, updatedAtMs: 0,
deleted: false, deleted: false,
playCount: 0, playCount: 0,
categoriesEdited: false,
); );
final item = songToMediaItem(song); final item = songToMediaItem(song);
+1
View File
@@ -16,6 +16,7 @@ Song _song(
updatedAtMs: 0, updatedAtMs: 0,
deleted: false, deleted: false,
playCount: playCount, playCount: playCount,
categoriesEdited: false,
); );
} }
+1
View File
@@ -12,6 +12,7 @@ Song _song({required String id, int? durationMs}) {
updatedAtMs: 0, updatedAtMs: 0,
deleted: false, deleted: false,
playCount: 0, playCount: 0,
categoriesEdited: false,
); );
} }
+2
View File
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import 'package:melo/library/database.dart'; import 'package:melo/library/database.dart';
import 'package:melo/library/library_service.dart'; import 'package:melo/library/library_service.dart';
import 'package:melo/services/offline_mode.dart'; import 'package:melo/services/offline_mode.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:melo/settings/settings_screen.dart'; import 'package:melo/settings/settings_screen.dart';
Widget _wrap(MeloDb db, Widget child) { Widget _wrap(MeloDb db, Widget child) {
@@ -14,6 +15,7 @@ Widget _wrap(MeloDb db, Widget child) {
Provider<MeloDb>.value(value: db), Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<OfflineMode>.value(value: OfflineMode()), ChangeNotifierProvider<OfflineMode>.value(value: OfflineMode()),
ChangeNotifierProvider<LibraryService>.value(value: LibraryService(db)), ChangeNotifierProvider<LibraryService>.value(value: LibraryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
], ],
child: MaterialApp(home: child), child: MaterialApp(home: child),
); );