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 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(); final settings = context.watch(); 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 _editCategories(BuildContext context, List current) async { final service = context.read(); final suggestions = await service.allNames(); if (!context.mounted) return; final result = await showDialog>( 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 current; final List suggestions; @override State<_CategoryEditor> createState() => _CategoryEditorState(); } class _CategoryEditorState extends State<_CategoryEditor> { late final List _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'), ), ], ); } }