diff --git a/lib/screens/track_metadata_edit_autofill.dart b/lib/screens/track_metadata_edit_autofill.dart index 7e653c2f..87d931c5 100644 --- a/lib/screens/track_metadata_edit_autofill.dart +++ b/lib/screens/track_metadata_edit_autofill.dart @@ -175,7 +175,7 @@ extension _EditMetadataSheetAutoFill on _EditMetadataSheetState { builder: (sheetContext) => ListView( padding: const EdgeInsets.only(bottom: 16), children: [ - _editorOptions( + MetadataEditorOptions( children: [ for (var index = 0; index < candidates.length; index++) ...[ MetadataCandidateTile( @@ -476,7 +476,8 @@ extension _EditMetadataSheetAutoFill on _EditMetadataSheetState { } Widget _buildAutoFillSection(ColorScheme cs) { - return _sectionCard( + return MetadataEditorSectionCard( + colorScheme: widget.colorScheme, icon: Icons.travel_explore, title: context.l10n.editMetadataAutoFill, onHeaderTap: () => _setState(() => _showAutoFill = !_showAutoFill), @@ -500,7 +501,8 @@ extension _EditMetadataSheetAutoFill on _EditMetadataSheetState { final selectedId = selectedProviderIsAvailable ? _selectedMetadataProviderId : null; - return _selectionField( + return MetadataEditorSelectionField( + colorScheme: widget.colorScheme, label: context.l10n.editMetadataAutoFillSource, value: _metadataProviderName(selectedId), enabled: !_fetching && !_saving, @@ -512,20 +514,23 @@ extension _EditMetadataSheetAutoFill on _EditMetadataSheetState { spacing: 8, runSpacing: 8, children: [ - _quickSelectButton( + MetadataEditorQuickSelectButton( label: context.l10n.editMetadataSelectAll, onTap: _selectAllFields, cs: cs, + fetching: _fetching, ), - _quickSelectButton( + MetadataEditorQuickSelectButton( label: context.l10n.editMetadataSelectEmpty, onTap: _selectEmptyFields, cs: cs, + fetching: _fetching, ), - _quickSelectButton( + MetadataEditorQuickSelectButton( label: context.l10n.editMetadataSelectNone, onTap: _selectNoFields, cs: cs, + fetching: _fetching, ), ], ), diff --git a/lib/screens/track_metadata_edit_cover.dart b/lib/screens/track_metadata_edit_cover.dart index c5e035f7..11ab602f 100644 --- a/lib/screens/track_metadata_edit_cover.dart +++ b/lib/screens/track_metadata_edit_cover.dart @@ -153,7 +153,8 @@ extension _EditMetadataSheetCover on _EditMetadataSheetState { Widget _buildCoverEditor(ColorScheme cs) { final hasSelectedCover = _hasValue(_selectedCoverPath); final hasCurrentCover = _hasValue(_currentCoverPath); - return _sectionCard( + return MetadataEditorSectionCard( + colorScheme: widget.colorScheme, icon: Icons.image_outlined, title: context.l10n.editMetadataFieldCover, children: [ @@ -248,7 +249,8 @@ extension _EditMetadataSheetCover on _EditMetadataSheetState { ), ], const SizedBox(height: 12), - _selectionField( + MetadataEditorSelectionField( + colorScheme: widget.colorScheme, label: context.l10n.trackCoverResolution, value: _coverMaxDimension == null ? _originalCoverResolutionLabel( diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index 1195475c..21376594 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -24,16 +24,14 @@ import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/app_action_button.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; -import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/widgets/app_progress_indicator.dart'; import 'package:spotiflac_android/widgets/app_icon_button.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; import 'package:spotiflac_android/widgets/app_snack_bar.dart'; -import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/widgets/metadata_candidate_tile.dart'; +import 'package:spotiflac_android/widgets/metadata_editor_fields.dart'; import 'package:spotiflac_android/widgets/metadata_text_field.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; -import 'package:spotiflac_android/widgets/settings_group.dart'; part 'track_metadata_edit_autofill.dart'; part 'track_metadata_edit_cover.dart'; @@ -133,7 +131,7 @@ class _EditMetadataSheetState extends State { Flexible( child: SingleChildScrollView( padding: const EdgeInsets.only(bottom: 16), - child: _editorOptions( + child: MetadataEditorOptions( children: [ for (var index = 0; index < items.length; index++) Column( @@ -373,7 +371,8 @@ class _EditMetadataSheetState extends State { children: [ _buildCoverEditor(cs), _buildAutoFillSection(cs), - _sectionCard( + MetadataEditorSectionCard( + colorScheme: widget.colorScheme, icon: Icons.info_outline, title: context.l10n.trackMetadata, children: [ @@ -457,7 +456,8 @@ class _EditMetadataSheetState extends State { ), ], ), - _sectionCard( + MetadataEditorSectionCard( + colorScheme: widget.colorScheme, icon: Icons.lyrics_outlined, title: context.l10n.trackLyrics, children: [ @@ -470,7 +470,8 @@ class _EditMetadataSheetState extends State { ), ], ), - _sectionCard( + MetadataEditorSectionCard( + colorScheme: widget.colorScheme, icon: Icons.tune, title: context.l10n.editMetadataAdvanced, onHeaderTap: () => @@ -499,7 +500,8 @@ class _EditMetadataSheetState extends State { _form['album_type']!, hint: context.l10n.editMetadataFieldAlbumTypeHint, ), - _switchField( + MetadataEditorSwitchField( + colorScheme: widget.colorScheme, label: context.l10n.editMetadataFieldExplicit, subtitle: context.l10n.editMetadataFieldExplicitHint, @@ -537,302 +539,4 @@ class _EditMetadataSheetState extends State { ), ); } - - Widget _editorOptions({required List children}) { - if (!context.isMornye) return SettingsGroup(children: children); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Material( - color: MornyeTheme.controlFill(context), - borderRadius: BorderRadius.circular(28), - clipBehavior: Clip.antiAlias, - child: Column(mainAxisSize: MainAxisSize.min, children: children), - ), - ); - } - - Widget _quickSelectButton({ - required String label, - required VoidCallback onTap, - required ColorScheme cs, - }) { - if (context.isMornye) { - return CupertinoButton( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - onPressed: _fetching ? null : onTap, - child: Text( - label, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.primary), - ), - ); - } - return InkWell( - onTap: _fetching ? null : onTap, - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: cs.outline.withValues(alpha: 0.5)), - ), - child: Text( - label, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: cs.primary), - ), - ), - ); - } - - Widget _switchField({ - required String label, - required String subtitle, - required bool value, - required ValueChanged onChanged, - }) { - final cs = widget.colorScheme; - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: Column( - children: [ - Material( - color: metadataEditorFieldFill(context, cs), - borderRadius: BorderRadius.circular(14), - clipBehavior: Clip.antiAlias, - child: AppSwitchListTile( - adaptive: true, - value: value, - onChanged: onChanged, - contentPadding: EdgeInsets.symmetric( - horizontal: context.isMornye ? 4 : 16, - ), - title: Text(label, style: Theme.of(context).textTheme.bodyLarge), - subtitle: Text( - subtitle, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), - ), - ), - if (context.isMornye) const Divider(height: 0.5), - ], - ), - ); - } - - Widget _selectionField({ - required String label, - required String value, - required VoidCallback onTap, - bool enabled = true, - }) { - final cs = widget.colorScheme; - if (context.isMornye) { - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 6), - child: Text( - label, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), - ), - CupertinoButton( - color: metadataEditorFieldFill(context, cs), - disabledColor: Colors.transparent, - borderRadius: BorderRadius.circular(16), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14), - onPressed: enabled ? onTap : null, - child: Row( - children: [ - Expanded( - child: Text( - value, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: enabled ? cs.onSurface : cs.onSurfaceVariant, - ), - ), - ), - const SizedBox(width: 12), - Icon( - CupertinoIcons.chevron_up_chevron_down, - size: 16, - color: cs.primary, - ), - ], - ), - ), - const Divider(height: 0.5), - ], - ), - ); - } - final radius = BorderRadius.circular(14); - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 6), - child: Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: enabled ? cs.onSurfaceVariant : cs.outline, - fontWeight: FontWeight.w600, - letterSpacing: 0.1, - ), - ), - ), - Material( - color: metadataEditorFieldFill(context, cs), - borderRadius: radius, - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: enabled ? onTap : null, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 12, 14), - child: Row( - children: [ - Expanded( - child: Text( - value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: enabled ? cs.onSurface : cs.outline, - ), - ), - ), - const SizedBox(width: 8), - Icon( - Icons.expand_more_rounded, - color: enabled ? cs.onSurfaceVariant : cs.outline, - ), - ], - ), - ), - ), - ), - ], - ), - ); - } - - RoundedRectangleBorder _sectionCardShape(ColorScheme cs) { - return RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)), - ); - } - - /// Titled section card. When [onHeaderTap] is set the header is a full-width - /// tappable row (ripple clipped to the card) with an auto chevron. - Widget _sectionCard({ - required IconData icon, - required String title, - required List children, - VoidCallback? onHeaderTap, - bool expanded = true, - }) { - final cs = widget.colorScheme; - final collapsible = onHeaderTap != null; - const inset = 16.0; - - final headerRow = Row( - children: [ - Icon(context.adaptiveIcon(icon), size: 20, color: cs.primary), - const SizedBox(width: 10), - Expanded( - child: Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: cs.onSurface, - ), - ), - ), - if (collapsible) - AnimatedRotation( - turns: expanded ? 0.5 : 0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - child: Icon( - context.isMornye - ? CupertinoIcons.chevron_down - : Icons.expand_more, - size: 22, - color: cs.onSurfaceVariant, - ), - ), - ], - ); - - final Widget header = collapsible - ? InkWell( - onTap: onHeaderTap, - child: Padding( - padding: EdgeInsets.fromLTRB(inset, 16, inset, 16), - child: headerRow, - ), - ) - : Padding( - padding: EdgeInsets.fromLTRB(inset, 16, inset, 8), - child: headerRow, - ); - - final content = AnimatedSize( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - alignment: Alignment.topCenter, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - header, - if (children.isNotEmpty) - Padding( - padding: EdgeInsets.fromLTRB(inset, 0, inset, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: children, - ), - ), - ], - ), - ); - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: context.isMornye - ? Material( - color: MornyeTheme.controlFill(context), - borderRadius: BorderRadius.circular(28), - clipBehavior: Clip.antiAlias, - child: DividerTheme( - data: DividerTheme.of( - context, - ).copyWith(color: MornyeTheme.metadataDividerColor(context)), - child: content, - ), - ) - : AppContentCard( - elevation: 0, - margin: EdgeInsets.zero, - color: settingsGroupColor(context), - shape: _sectionCardShape(cs), - clipBehavior: Clip.antiAlias, - child: content, - ), - ); - } } diff --git a/lib/widgets/metadata_editor_fields.dart b/lib/widgets/metadata_editor_fields.dart new file mode 100644 index 00000000..5051ecb0 --- /dev/null +++ b/lib/widgets/metadata_editor_fields.dart @@ -0,0 +1,366 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:spotiflac_android/widgets/metadata_text_field.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +/// Grouped option list used by the metadata editor pickers. +class MetadataEditorOptions extends StatelessWidget { + final List children; + + const MetadataEditorOptions({super.key, required this.children}); + + @override + Widget build(BuildContext context) { + if (!context.isMornye) return SettingsGroup(children: children); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Material( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + child: Column(mainAxisSize: MainAxisSize.min, children: children), + ), + ); + } +} + +class MetadataEditorQuickSelectButton extends StatelessWidget { + final String label; + final VoidCallback onTap; + final ColorScheme cs; + final bool fetching; + + const MetadataEditorQuickSelectButton({ + super.key, + required this.label, + required this.onTap, + required this.cs, + required this.fetching, + }); + + @override + Widget build(BuildContext context) { + if (context.isMornye) { + return CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + onPressed: fetching ? null : onTap, + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.primary), + ), + ); + } + return InkWell( + onTap: fetching ? null : onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outline.withValues(alpha: 0.5)), + ), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: cs.primary), + ), + ), + ); + } +} + +class MetadataEditorSwitchField extends StatelessWidget { + final ColorScheme colorScheme; + final String label; + final String subtitle; + final bool value; + final ValueChanged onChanged; + + const MetadataEditorSwitchField({ + super.key, + required this.colorScheme, + required this.label, + required this.subtitle, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final cs = colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + children: [ + Material( + color: metadataEditorFieldFill(context, cs), + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: AppSwitchListTile( + adaptive: true, + value: value, + onChanged: onChanged, + contentPadding: EdgeInsets.symmetric( + horizontal: context.isMornye ? 4 : 16, + ), + title: Text(label, style: Theme.of(context).textTheme.bodyLarge), + subtitle: Text( + subtitle, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ), + ), + if (context.isMornye) const Divider(height: 0.5), + ], + ), + ); + } +} + +class MetadataEditorSelectionField extends StatelessWidget { + final ColorScheme colorScheme; + final String label; + final String value; + final VoidCallback onTap; + final bool enabled; + + const MetadataEditorSelectionField({ + super.key, + required this.colorScheme, + required this.label, + required this.value, + required this.onTap, + this.enabled = true, + }); + + @override + Widget build(BuildContext context) { + final cs = colorScheme; + if (context.isMornye) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ), + CupertinoButton( + color: metadataEditorFieldFill(context, cs), + disabledColor: Colors.transparent, + borderRadius: BorderRadius.circular(16), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14), + onPressed: enabled ? onTap : null, + child: Row( + children: [ + Expanded( + child: Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: enabled ? cs.onSurface : cs.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + Icon( + CupertinoIcons.chevron_up_chevron_down, + size: 16, + color: cs.primary, + ), + ], + ), + ), + const Divider(height: 0.5), + ], + ), + ); + } + final radius = BorderRadius.circular(14); + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: enabled ? cs.onSurfaceVariant : cs.outline, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + ), + ), + ), + Material( + color: metadataEditorFieldFill(context, cs), + borderRadius: radius, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: enabled ? onTap : null, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 12, 14), + child: Row( + children: [ + Expanded( + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: enabled ? cs.onSurface : cs.outline, + ), + ), + ), + const SizedBox(width: 8), + Icon( + Icons.expand_more_rounded, + color: enabled ? cs.onSurfaceVariant : cs.outline, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +/// Titled section card. When [onHeaderTap] is set the header is a full-width +/// tappable row (ripple clipped to the card) with an auto chevron. +class MetadataEditorSectionCard extends StatelessWidget { + final ColorScheme colorScheme; + final IconData icon; + final String title; + final List children; + final VoidCallback? onHeaderTap; + final bool expanded; + + const MetadataEditorSectionCard({ + super.key, + required this.colorScheme, + required this.icon, + required this.title, + required this.children, + this.onHeaderTap, + this.expanded = true, + }); + + RoundedRectangleBorder _sectionCardShape(ColorScheme cs) { + return RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)), + ); + } + + @override + Widget build(BuildContext context) { + final cs = colorScheme; + final collapsible = onHeaderTap != null; + const inset = 16.0; + + final headerRow = Row( + children: [ + Icon(context.adaptiveIcon(icon), size: 20, color: cs.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + ), + if (collapsible) + AnimatedRotation( + turns: expanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + child: Icon( + context.isMornye + ? CupertinoIcons.chevron_down + : Icons.expand_more, + size: 22, + color: cs.onSurfaceVariant, + ), + ), + ], + ); + + final Widget header = collapsible + ? InkWell( + onTap: onHeaderTap, + child: Padding( + padding: EdgeInsets.fromLTRB(inset, 16, inset, 16), + child: headerRow, + ), + ) + : Padding( + padding: EdgeInsets.fromLTRB(inset, 16, inset, 8), + child: headerRow, + ); + + final content = AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + header, + if (children.isNotEmpty) + Padding( + padding: EdgeInsets.fromLTRB(inset, 0, inset, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ), + ], + ), + ); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: context.isMornye + ? Material( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + child: DividerTheme( + data: DividerTheme.of( + context, + ).copyWith(color: MornyeTheme.metadataDividerColor(context)), + child: content, + ), + ) + : AppContentCard( + elevation: 0, + margin: EdgeInsets.zero, + color: settingsGroupColor(context), + shape: _sectionCardShape(cs), + clipBehavior: Clip.antiAlias, + child: content, + ), + ); + } +}