Files
Melo/lib/library/song_detail_sheet.dart
T
Hermes (Server)andClaude Opus 5 bc861f6087 UI-Politur: Kontrast, Abstaende, Typografie, Bewegung
Kein Redesign — Schwarz/Rot, 4 Tabs und Lieder/Kategorie bleiben. Grundlage
sind die Design-Skills: ui-ux-pro-max (Barrierefreiheit, Touch, Typografie,
Bewegung), ui-design/mobile-android (Material 3) und die Token-Disziplin
aus Hue.

Behobene Maengel (messbar, nicht Geschmack):
- Text in Colors.white38 an 17 Stellen = 3,4:1 Kontrast, WCAG verlangt
  4,5:1. Ersetzt durch drei benannte Stufen (text1 15,9:1 / text2 8,8:1 /
  text3 4,9:1). theme_kontrast_test.dart rechnet die Verhaeltnisse bei
  jedem Lauf nach, statt sie zu behaupten.
- SubTabs waren ~38 dp hoch (Material 3 verlangt 48), ohne Ripple und ohne
  Uebergang. Jetzt 48 dp, InkWell, AnimatedContainer, und die Auswahl wird
  Vorlesehilfen als Zustand gemeldet (Semantics.selected).
- mini_player.dart: Expanded UM eine feste Hoehe herum — zwei
  widerspruechliche Angaben. Entschaerft.
- Emoji in Bedienelementen der Einstellungen (jeweils neben einem echten
  Icon) entfernt.

Neu in shared/theme.dart: MeloSpace (8er-Raster), MeloRadius, MeloMotion,
Farbrollen text1/2/3 + border + hairline, minTouchTarget, vollstaendiges
textTheme (7 Stufen) und Themes fuer Listen, Sheets, Snackbars,
Fortschritt, Trenner.

318 Tests gruen (23 neue, 1 uebersprungen), flutter analyze ohne Befund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
2026-08-21 12:40:25 +02:00

457 lines
15 KiB
Dart

import 'dart:io';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/musicbrainz_service.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: MeloTheme.text2)),
if (song.album != null) ...[
const SizedBox(height: 2),
Text(song.album!,
style: const TextStyle(
color: MeloTheme.text3, 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: MeloTheme.text3, 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: MeloTheme.text3, 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),
_OnlineLookup(song: song),
],
),
);
}
}
/// Holt zum Song passende Metadaten von MusicBrainz. Ein Tipp auf einen
/// Vorschlag übernimmt Titel, Künstler und Album — gespeichert wird über
/// [MeloDb.upsertSongs], denselben Weg, den auch der Scan nimmt.
class _OnlineLookup extends StatefulWidget {
const _OnlineLookup({required this.song});
final Song song;
@override
State<_OnlineLookup> createState() => _OnlineLookupState();
}
class _OnlineLookupState extends State<_OnlineLookup> {
final _dienst = MusicBrainzService();
bool _laeuft = false;
List<MbVorschlag>? _vorschlaege;
Future<void> _nachschlagen() async {
setState(() => _laeuft = true);
try {
final gefunden = await _dienst.suche(
titel: widget.song.title,
kuenstler: widget.song.artist,
);
if (!mounted) return;
setState(() {
_vorschlaege = gefunden;
_laeuft = false;
});
} catch (e) {
debugPrint('MusicBrainz nicht erreichbar: $e');
if (!mounted) return;
setState(() => _laeuft = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('MusicBrainz nicht erreichbar')),
);
}
}
/// Übernimmt [vorschlag]; leere Angaben lassen den bisherigen Wert stehen.
Future<void> _uebernehmen(MbVorschlag vorschlag) async {
final song = widget.song;
final db = context.read<MeloDb>();
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
await db.upsertSongs([metadatenUebernahme(song, vorschlag)]);
if (!mounted) return;
// Das Sheet zeigt eine Kopie des Songs — geschlossen wirkt die Änderung
// sofort in der Liste darunter.
navigator.pop();
messenger.showSnackBar(
const SnackBar(content: Text('Metadaten übernommen')),
);
}
@override
Widget build(BuildContext context) {
final vorschlaege = _vorschlaege;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
TextButton.icon(
icon: const Icon(Icons.travel_explore, size: 16),
label: const Text('Online nachschlagen'),
onPressed: _laeuft ? null : _nachschlagen,
),
if (_laeuft) ...[
const SizedBox(width: 8),
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: MeloTheme.red),
),
],
],
),
if (vorschlaege != null && vorschlaege.isEmpty)
const Text('Nichts gefunden',
style: TextStyle(color: MeloTheme.text3, fontSize: 13)),
if (vorschlaege != null && vorschlaege.isNotEmpty) ...[
const Text('Tippen übernimmt Titel, Künstler und Album',
style: TextStyle(color: MeloTheme.text3, fontSize: 12)),
for (final vorschlag in vorschlaege)
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(vorschlag.titel,
style: const TextStyle(fontSize: 14)),
subtitle: Text(
[vorschlag.kuenstler, vorschlag.album]
.where((t) => t.isNotEmpty)
.join(' — '),
style: const TextStyle(color: MeloTheme.text3, fontSize: 12),
),
onTap: () => _uebernehmen(vorschlag),
),
],
],
);
}
}
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: MeloTheme.text3, 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: MeloTheme.text3, fontSize: 12),
),
const SizedBox(height: 12),
if (_names.isEmpty)
const Text('Noch keine Kategorie',
style: TextStyle(color: MeloTheme.text3, 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: MeloTheme.text3, 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'),
),
],
);
}
}
/// Baut den Datenbank-Eintrag für einen übernommenen Online-Vorschlag.
/// Leere Angaben lassen den bisherigen Wert stehen.
///
/// Setzt [Songs.metadataEdited] — ohne diese Markierung holt der nächste
/// Bibliotheks-Scan die falschen Tags der Datei zurück und die Korrektur
/// wäre wieder weg.
SongsCompanion metadatenUebernahme(Song song, MbVorschlag vorschlag) {
return SongsCompanion.insert(
id: song.id,
path: song.path,
title: vorschlag.titel.isEmpty ? song.title : vorschlag.titel,
artist:
Value(vorschlag.kuenstler.isEmpty ? song.artist : vorschlag.kuenstler),
album: Value(vorschlag.album.isEmpty ? song.album : vorschlag.album),
metadataEdited: const Value(true),
dateAddedMs: song.dateAddedMs,
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
);
}