import 'package:file_picker/file_picker.dart'; import '../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../focus/focusable_wrapper.dart'; import '../focus/focusable_button.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../metadata_edit/metadata_edit_adapters.dart'; import '../metadata_edit/metadata_edit_models.dart'; import '../services/file_picker_service.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; import '../utils/formatters.dart'; import '../utils/provider_extensions.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/app_icon.dart'; import '../widgets/dialog_action_button.dart'; import '../widgets/focusable_list_tile.dart'; import '../widgets/focused_scroll_scaffold.dart'; import '../widgets/loading_indicator_box.dart'; import '../widgets/optimized_media_image.dart'; import '../widgets/tag_edit_dialog.dart'; class MetadataEditScreen extends StatefulWidget { final MediaItem metadata; const MetadataEditScreen({super.key, required this.metadata}); @override State createState() => _MetadataEditScreenState(); } class _MetadataEditScreenState extends State { MetadataEditAdapter? _adapter; MetadataEditDraft? _draft; bool _isLoading = true; bool _isCommitting = false; int _loadGeneration = 0; @override void initState() { super.initState(); _loadMetadata(); } @override void didUpdateWidget(covariant MetadataEditScreen oldWidget) { super.didUpdateWidget(oldWidget); if (_sameMedia(oldWidget.metadata, widget.metadata)) return; _adapter = null; _draft = null; _isLoading = true; _loadMetadata(); } Future _loadMetadata() async { final generation = ++_loadGeneration; final metadata = widget.metadata; try { final client = context.getMediaClientWithFallback(serverIdOrNull(metadata.serverId)); final adapter = metadataEditAdapterFor(client); if (adapter == null || !adapter.supportsKind(metadata.kind)) { if (!mounted || generation != _loadGeneration || !_sameMedia(widget.metadata, metadata)) return; setState(() { _adapter = adapter; _isLoading = false; }); return; } final draft = await adapter.load(metadata); if (!mounted || generation != _loadGeneration || !_sameMedia(widget.metadata, metadata)) return; setState(() { _adapter = adapter; _draft = draft; _isLoading = false; }); } catch (e, st) { appLogger.e('Failed to load metadata editor', error: e, stackTrace: st); if (!mounted || generation != _loadGeneration || !_sameMedia(widget.metadata, metadata)) return; setState(() => _isLoading = false); showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); } } bool get _hasChanges { final adapter = _adapter; final draft = _draft; return adapter != null && draft != null && adapter.hasChanges(draft); } Future _save() async { final adapter = _adapter; final draft = _draft; final metadata = widget.metadata; if (adapter == null || draft == null || !_hasChanges || _isCommitting) return; setState(() => _isCommitting = true); bool success = false; try { success = await adapter.save(draft); } catch (e, st) { appLogger.e('Failed to update metadata', error: e, stackTrace: st); } if (!mounted) return; final ownsCompletion = identical(_draft, draft) && _sameMedia(widget.metadata, metadata); setState(() => _isCommitting = false); if (!ownsCompletion) return; if (success) { showSuccessSnackBar(context, t.metadataEdit.metadataUpdated); Navigator.pop(context, true); } else { showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); } } Future _editTextField(MetadataEditField field, {bool multiline = false}) async { final draft = _draft; if (draft == null || _isCommitting) return; final currentValue = draft.value(field.id) ?? ''; final result = await showTextInputDialog( context, title: field.label, labelText: field.label, initialValue: currentValue, allowEmpty: true, multiline: multiline, ); if (result != null && mounted && !_isCommitting && identical(_draft, draft)) { setState(() => draft.setValue(field.id, result)); } } Future _editDate(MetadataEditField field) async { final draft = _draft; if (draft == null || _isCommitting) return; DateTime initial = DateTime.now(); final current = draft.value(field.id); if (current != null && current.isNotEmpty) { final parsed = DateTime.tryParse(current); if (parsed != null) initial = parsed; } final picked = await showDatePicker( context: context, initialDate: initial, firstDate: DateTime(1800), lastDate: DateTime(2200), useRootNavigator: false, ); if (picked != null && mounted && !_isCommitting && identical(_draft, draft)) { setState(() { draft.setValue(field.id, '${picked.year}-${padNumber(picked.month, 2)}-${padNumber(picked.day, 2)}'); }); } } Future _editStringList(MetadataEditField field) async { final draft = _draft; if (draft == null || _isCommitting) return; final result = await showScopedDialog>( context: context, builder: (context) => TagEditDialog(title: field.label, initialTags: metadataStringList(draft.values[field.id])), ); if (result != null && mounted && !_isCommitting && identical(_draft, draft)) { setState(() => draft.setValue(field.id, result)); } } Future _editChoice(MetadataEditField field) async { final adapter = _adapter; final draft = _draft; if (adapter == null || draft == null || _isCommitting) return; final current = draft.value(field.id) ?? ''; final result = await showScopedDialog( context: context, builder: (dialogContext) { String selected = current; return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( title: Text(field.label), content: SizedBox( width: double.maxFinite, child: RadioGroup( groupValue: field.options.any((option) => option.value == selected) ? selected : null, onChanged: (value) { if (value == null) return; setDialogState(() => selected = value); Navigator.pop(dialogContext, value); }, child: ListView( shrinkWrap: true, children: [ for (final option in field.options) FocusableRadioListTile( key: ValueKey(option.value), title: Text(option.label), value: option.value, ), ], ), ), ), actions: [DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel)], ); }, ); }, ); if (result == null || !mounted || _isCommitting || !identical(_draft, draft)) return; if (field.saveMode == MetadataEditSaveMode.immediate) { final metadata = widget.metadata; final previous = draft.values[field.id]; setState(() { draft.setValue(field.id, result); _isCommitting = true; }); bool success = false; try { success = await adapter.saveImmediateField(draft, field, result); } catch (e, st) { appLogger.e('Failed to update metadata field', error: e, stackTrace: st); } if (!mounted) return; final ownsCompletion = identical(_draft, draft) && _sameMedia(widget.metadata, metadata); setState(() { _isCommitting = false; if (!success && ownsCompletion && metadataEditValueEquals(draft.values[field.id], result)) { draft.setValue(field.id, previous); } }); if (!ownsCompletion) return; if (!success) showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); } else { setState(() => draft.setValue(field.id, result)); } } Future _openArtworkPicker(MetadataEditField field) async { final adapter = _adapter; final draft = _draft; if (adapter == null || draft == null || _isCommitting) return; final result = await showScopedDialog( context: context, builder: (context) => ArtworkPickerDialog(adapter: adapter, draft: draft, field: field), ); if (result == true && mounted && !_isCommitting && identical(_draft, draft)) { await _reloadArtwork(); } } Future _reloadArtwork() async { final adapter = _adapter; final draft = _draft; if (adapter == null || draft == null) return; try { final item = await adapter.reloadItem(draft); if (!mounted) return; if (item != null) { setState(() => adapter.syncReloadedItem(draft, item)); } } catch (_) { // Artwork was already saved by the picker; display will refresh next time. } } @override Widget build(BuildContext context) { if (_isLoading) { return FocusedScrollScaffold(title: Text(t.metadataEdit.screenTitle), slivers: [LoadingIndicatorBox.sliver]); } final adapter = _adapter; final draft = _draft; if (adapter == null || draft == null) { return FocusedScrollScaffold( title: Text(t.metadataEdit.screenTitle), slivers: [SliverFillRemaining(child: Center(child: Text(t.metadataEdit.metadataUpdateFailed)))], ); } final sections = adapter.schemaFor(draft).where((section) => section.fields.isNotEmpty).toList(); return PopScope( canPop: !_isCommitting, child: FocusedScrollScaffold( title: Text(t.metadataEdit.screenTitle), focusableAppBarActions: true, actions: [ if (_isCommitting) const Padding(padding: .all(12), child: LoadingIndicatorBox(size: 24)) else FocusableButton( onPressed: _hasChanges ? _save : null, child: IconButton( onPressed: _hasChanges ? _save : null, icon: const AppIcon(Symbols.check_rounded, fill: 1), tooltip: t.common.save, ), ), ], slivers: [ SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList.separated( itemCount: sections.length, separatorBuilder: (context, index) => const SizedBox(height: 16), itemBuilder: (context, index) => _buildSectionCard(adapter, draft, sections[index]), ), ), ], ), ); } Widget _buildSectionCard(MetadataEditAdapter adapter, MetadataEditDraft draft, MetadataEditSection section) { return Card( child: Column( crossAxisAlignment: .start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text(section.title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: .bold)), ), for (final field in section.fields) _buildField(adapter, draft, field), ], ), ); } Widget _buildField(MetadataEditAdapter adapter, MetadataEditDraft draft, MetadataEditField field) { return switch (field.type) { MetadataEditFieldType.text => _buildFieldTile( label: field.label, value: draft.value(field.id), onTap: () => _editTextField(field), ), MetadataEditFieldType.multilineText => _buildFieldTile( label: field.label, value: draft.value(field.id), onTap: () => _editTextField(field, multiline: true), ), MetadataEditFieldType.date => _buildFieldTile( label: field.label, value: draft.value(field.id), onTap: () => _editDate(field), ), MetadataEditFieldType.stringList => _buildFieldTile( label: field.label, value: metadataStringList(draft.values[field.id]).join(', '), onTap: () => _editStringList(field), ), MetadataEditFieldType.choice => _buildFieldTile( label: field.label, value: _choiceDisplayValue(field, draft.value(field.id)), onTap: () => _editChoice(field), ), MetadataEditFieldType.artwork => _buildArtworkTile(adapter, draft, field), }; } Widget _buildFieldTile({required String label, String? value, required VoidCallback onTap}) { final displayValue = (value == null || value.isEmpty) ? t.metadataEdit.notSet : value; final isNotSet = value == null || value.isEmpty; return FocusableListTile( title: Text(label), subtitle: Text( displayValue, maxLines: 2, overflow: .ellipsis, style: isNotSet ? TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)) : null, ), trailing: const AppIcon(Symbols.chevron_right_rounded), enabled: !_isCommitting, onTap: _isCommitting ? null : onTap, dense: false, visualDensity: VisualDensity.standard, ); } Widget _buildArtworkTile(MetadataEditAdapter adapter, MetadataEditDraft draft, MetadataEditField field) { final artwork = field.artwork!; final imagePath = draft.value(field.id); return FocusableListTile( leading: SizedBox( width: artwork.previewWidth, height: artwork.previewHeight, child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(4)), child: OptimizedMediaImage( client: adapter.mediaClient, imagePath: imagePath, width: artwork.previewWidth, height: artwork.previewHeight, fit: artwork.fit == MetadataArtworkFit.contain ? BoxFit.contain : BoxFit.cover, imageType: artwork.imageType, ), ), ), title: Text(field.label), trailing: const AppIcon(Symbols.chevron_right_rounded), enabled: !_isCommitting, onTap: _isCommitting ? null : () => _openArtworkPicker(field), dense: false, visualDensity: VisualDensity.standard, ); } String _choiceDisplayValue(MetadataEditField field, String? value) { for (final option in field.options) { if (option.value == value) return option.label; } return value == null || value.isEmpty ? t.metadataEdit.notSet : value; } } bool _sameMedia(MediaItem left, MediaItem right) { return left.id == right.id && left.backend == right.backend && left.serverId == right.serverId; } class ArtworkPickerDialog extends StatefulWidget { final MetadataEditAdapter adapter; final MetadataEditDraft draft; final MetadataEditField field; const ArtworkPickerDialog({super.key, required this.adapter, required this.draft, required this.field}); @override State createState() => _ArtworkPickerDialogState(); } class _ArtworkPickerDialogState extends State { List? _artworkList; bool _isLoading = true; bool _isApplying = false; MetadataArtworkConfig get _config => widget.field.artwork!; @override void initState() { super.initState(); _loadArtwork(); } Future _loadArtwork() async { try { final artwork = await widget.adapter.fetchArtwork(widget.draft, widget.field); if (!mounted) return; setState(() { _artworkList = artwork; _isLoading = false; }); } catch (e, st) { appLogger.e('Failed to load artwork', error: e, stackTrace: st); if (!mounted) return; setState(() { _artworkList = const []; _isLoading = false; }); } } Future _selectArtwork(MetadataArtworkOption artwork) async { if (_isApplying) return; await _runArtworkUpdate(() => widget.adapter.applyArtworkOption(widget.draft, widget.field, artwork)); } Future _addFromUrl() async { final url = await showTextInputDialog( context, title: t.metadataEdit.fromUrl, labelText: t.metadataEdit.imageUrl, hintText: t.metadataEdit.enterImageUrl, ); if (url == null || url.isEmpty || !mounted) return; await _runArtworkUpdate(() => widget.adapter.applyArtworkFromUrl(widget.draft, widget.field, url)); } Future _uploadFile() async { final result = await FilePickerService.instance.pickFiles(type: FileType.image, withData: true); if (result == null || result.files.isEmpty || !mounted) return; final file = result.files.first; final bytes = file.bytes; if (bytes == null) return; await _runArtworkUpdate(() => widget.adapter.uploadArtwork(widget.draft, widget.field, bytes, fileName: file.name)); } Future _runArtworkUpdate(Future Function() action) async { if (_isApplying) return; setState(() => _isApplying = true); bool success = false; try { success = await action(); } catch (e, st) { appLogger.e('Artwork update failed', error: e, stackTrace: st); } if (!mounted) return; setState(() => _isApplying = false); if (success) { showSuccessSnackBar(context, t.metadataEdit.artworkUpdated); Navigator.pop(context, true); } else { showErrorSnackBar(context, t.metadataEdit.artworkUpdateFailed); } } @override Widget build(BuildContext context) { return PopScope( canPop: !_isApplying, child: AlertDialog( title: Text(_config.selectTitle), content: SizedBox( width: 500, height: 400, child: _isLoading ? const Center(child: CircularProgressIndicator()) : _buildArtworkContent(), ), actions: [ if (_isApplying) const Padding(padding: .all(8), child: LoadingIndicatorBox(size: 24)), DialogActionButton( onPressed: _isApplying ? null : _addFromUrl, label: t.metadataEdit.fromUrl, icon: const AppIcon(Symbols.link_rounded, size: 18), ), DialogActionButton( onPressed: _isApplying ? null : _uploadFile, label: t.metadataEdit.uploadFile, icon: const AppIcon(Symbols.upload_rounded, size: 18), ), DialogActionButton( autofocus: true, onPressed: _isApplying ? null : () => Navigator.pop(context), label: t.common.cancel, ), ], ), ); } Widget _buildArtworkContent() { if (_artworkList == null || _artworkList!.isEmpty) { return Center(child: Text(t.metadataEdit.noArtworkAvailable)); } return GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: _config.gridColumns, crossAxisSpacing: 8, mainAxisSpacing: 8, childAspectRatio: _config.gridAspectRatio, ), itemCount: _artworkList!.length, itemBuilder: (context, index) { final artwork = _artworkList![index]; final VoidCallback? onSelect = _isApplying ? null : () => _selectArtwork(artwork); return FocusableWrapper( borderRadius: 8, semanticLabel: artwork.selected ? t.metadataEdit.selectedArtworkOption(index: index + 1) : t.metadataEdit.artworkOption(index: index + 1), canRequestFocus: !_isApplying, onSelect: onSelect, child: GestureDetector( onTap: onSelect, child: Stack( fit: StackFit.expand, children: [ Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.all(Radius.circular(8)), ), child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), child: OptimizedMediaImage( client: widget.adapter.mediaClient, imagePath: artwork.thumbnailPath, fit: _config.fit == MetadataArtworkFit.contain ? BoxFit.contain : BoxFit.cover, imageType: _config.imageType, ), ), ), if (artwork.selected) Positioned( right: 6, bottom: 6, child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration(color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle), child: AppIcon(Symbols.check_rounded, size: 16, color: Theme.of(context).colorScheme.onPrimary), ), ), ], ), ), ); }, ); } }