refactor(ui): consolidate duplicated sheets, rows, and cover widgets

- single-track convert sheet now reuses BatchConvertSheet
  (confirmLabelBuilder + sourceIsLossless params) instead of a
  full inline copy
- provider priority page migrated to PrioritySettingsScaffold;
  shared showDiscardChangesDialog and ReorderablePriorityItem
  replace per-page copies
- home tab search rows unified into one _SearchResultRowItem;
  _parseTrack copies rebuilt on Track.fromBackendMap with only
  the load-bearing local overrides kept; loading/error scaffold
  extracted
- PlayerArtwork widget shared by now-playing and mini player
- LocalOrNetworkCoverImage dispatches file vs network cover in
  one place (playlist picker, queue nav, library folder)
This commit is contained in:
zarzet
2026-07-12 18:58:16 +07:00
parent 4e0cae9c20
commit 4b9853eef7
17 changed files with 689 additions and 1586 deletions
+25 -4
View File
@@ -9,7 +9,15 @@ class BatchConvertSheet extends StatefulWidget {
final List<String> formats;
final String title;
final String? subtitle;
final String confirmLabel;
final String? confirmLabel;
final String Function(
String format,
String bitrate,
bool isLosslessTarget,
LosslessConversionQuality losslessQuality,
)?
confirmLabelBuilder;
final bool sourceIsLossless;
final int? sourceBitDepth;
final int? sourceSampleRate;
final void Function(
@@ -24,8 +32,10 @@ class BatchConvertSheet extends StatefulWidget {
super.key,
required this.formats,
required this.title,
required this.confirmLabel,
required this.onConvert,
this.confirmLabel,
this.confirmLabelBuilder,
this.sourceIsLossless = true,
this.subtitle,
this.sourceBitDepth,
this.sourceSampleRate,
@@ -317,7 +327,7 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
),
),
if (_isLosslessTarget)
if (_isLosslessTarget && widget.sourceIsLossless)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
@@ -383,7 +393,18 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
borderRadius: BorderRadius.circular(14),
),
),
label: Text(widget.confirmLabel),
label: Text(
widget.confirmLabelBuilder?.call(
_selectedFormat,
_selectedBitrate,
_isLosslessTarget,
LosslessConversionQuality(
maxBitDepth: _selectedMaxBitDepth,
maxSampleRate: _selectedMaxSampleRate,
),
) ??
widget.confirmLabel!,
),
),
),
],
+90
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
@@ -83,6 +85,94 @@ class CachedCoverImage extends StatelessWidget {
}
}
/// Renders [url] as a local file (when it's not an http/https URL) or as a
/// cached network image otherwise, with a shared [placeholder] used for the
/// local error state, the local not-ready frame, and the network
/// placeholder/error states alike.
class LocalOrNetworkCoverImage extends StatelessWidget {
final String url;
final double? width;
final double? height;
final BoxFit fit;
final BorderRadius? borderRadius;
final int? localCacheWidth;
final int? networkCacheWidth;
final Duration? fadeInDuration;
final Duration fadeOutDuration;
final String Function(String)? urlTransform;
final Widget Function(BuildContext) placeholder;
const LocalOrNetworkCoverImage({
super.key,
required this.url,
this.width,
this.height,
this.fit = BoxFit.cover,
this.borderRadius,
this.localCacheWidth,
this.networkCacheWidth,
this.fadeInDuration,
this.fadeOutDuration = Duration.zero,
this.urlTransform,
required this.placeholder,
});
bool get _isLocal =>
!url.startsWith('http://') && !url.startsWith('https://');
@override
Widget build(BuildContext context) {
if (_isLocal) {
final image = Image.file(
File(url),
width: width,
height: height,
fit: fit,
cacheWidth: localCacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
frameBuilder: fadeInDuration == null
? null
: (context, child, frame, wasSynchronouslyLoaded) {
final ready = wasSynchronouslyLoaded || frame != null;
if (fadeInDuration == Duration.zero) {
return ready ? child : placeholder(context);
}
return Stack(
fit: StackFit.expand,
children: [
placeholder(context),
AnimatedOpacity(
opacity: ready ? 1.0 : 0.0,
duration: fadeInDuration!,
curve: Curves.easeOutCubic,
child: child,
),
],
);
},
errorBuilder: (_, _, _) => placeholder(context),
);
return borderRadius == null
? image
: ClipRRect(borderRadius: borderRadius!, child: image);
}
return CachedCoverImage(
imageUrl: urlTransform?.call(url) ?? url,
width: width,
height: height,
fit: fit,
memCacheWidth: networkCacheWidth,
borderRadius: borderRadius,
fadeInDuration: fadeInDuration ?? Duration.zero,
fadeOutDuration: fadeOutDuration,
placeholder: (_, _) => placeholder(context),
errorWidget: (_, _, _) => placeholder(context),
);
}
}
CachedNetworkImageProvider cachedCoverImageProvider(String url) {
return CachedNetworkImageProvider(
url,
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Shared discard-unsaved-changes confirmation dialog used by priority /
/// selection settings pages.
Future<bool> showDiscardChangesDialog(
BuildContext context, {
String? content,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogDiscardChanges),
content: Text(content ?? context.l10n.dialogUnsavedChanges),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.dialogDiscard),
),
],
),
);
return result ?? false;
}
+4 -47
View File
@@ -1,11 +1,8 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/screens/now_playing_screen.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class MiniPlayer extends ConsumerWidget {
@@ -62,9 +59,11 @@ class MiniPlayer extends ConsumerWidget {
child: SizedBox(
width: 44,
height: 44,
child: _MiniArt(
child: PlayerArtwork(
artUri: mediaItem.artUri?.toString(),
colorScheme: colorScheme,
cacheWidth: 132,
iconSize: 22,
),
),
),
@@ -109,45 +108,3 @@ class MiniPlayer extends ConsumerWidget {
);
}
}
class _MiniArt extends StatelessWidget {
final String? artUri;
final ColorScheme colorScheme;
const _MiniArt({required this.artUri, required this.colorScheme});
@override
Widget build(BuildContext context) {
final placeholder = Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 22,
color: colorScheme.onSurfaceVariant,
),
);
final uri = artUri;
if (uri == null || uri.isEmpty) return placeholder;
if (uri.startsWith('http')) {
return CachedNetworkImage(
imageUrl: uri,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
memCacheWidth: 132,
fadeInDuration: const Duration(milliseconds: 150),
fadeOutDuration: const Duration(milliseconds: 0),
placeholder: (_, _) => placeholder,
errorWidget: (_, _, _) => placeholder,
);
}
if (uri.startsWith('file://')) {
return Image.file(
File(Uri.parse(uri).toFilePath()),
fit: BoxFit.cover,
cacheWidth: 132,
errorBuilder: (_, _, _) => placeholder,
);
}
return placeholder;
}
}
+58
View File
@@ -0,0 +1,58 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
class PlayerArtwork extends StatelessWidget {
final String? artUri;
final ColorScheme colorScheme;
final int? cacheWidth;
final double iconSize;
const PlayerArtwork({
super.key,
required this.artUri,
required this.colorScheme,
this.cacheWidth,
this.iconSize = 40,
});
@override
Widget build(BuildContext context) {
final placeholder = Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: iconSize,
color: colorScheme.onSurfaceVariant,
),
);
final uri = artUri;
if (uri == null || uri.isEmpty) return placeholder;
if (uri.startsWith('http')) {
return CachedNetworkImage(
imageUrl: uri,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
memCacheWidth: cacheWidth,
fadeInDuration: const Duration(milliseconds: 150),
fadeOutDuration: const Duration(milliseconds: 0),
placeholder: (_, _) => placeholder,
errorWidget: (_, _, _) => placeholder,
);
}
if (uri.startsWith('file://')) {
final path = Uri.parse(uri).toFilePath();
return Image.file(
File(path),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
errorBuilder: (_, _, _) => placeholder,
);
}
return placeholder;
}
}
+5 -23
View File
@@ -1,12 +1,11 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
Future<void> showAddTrackToPlaylistSheet(
BuildContext context,
@@ -383,29 +382,12 @@ class _PlaylistPickerThumbnail extends StatelessWidget {
final firstCoverUrl = playlist.previewCover;
if (firstCoverUrl != null) {
final isLocalPath =
!firstCoverUrl.startsWith('http://') &&
!firstCoverUrl.startsWith('https://');
if (isLocalPath) {
return Image.file(
File(firstCoverUrl),
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _iconFallback(colorScheme, size),
);
}
return CachedNetworkImage(
imageUrl: firstCoverUrl,
return LocalOrNetworkCoverImage(
url: firstCoverUrl,
width: size,
height: size,
fit: BoxFit.cover,
memCacheWidth: (size * 2).toInt(),
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => _iconFallback(colorScheme, size),
errorWidget: (_, _, _) => _iconFallback(colorScheme, size),
networkCacheWidth: (size * 2).toInt(),
placeholder: (_) => _iconFallback(colorScheme, size),
);
}
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
/// Shared numbered, draggable row used by provider-priority reorder lists.
class ReorderablePriorityItem extends StatelessWidget {
final int index;
final bool isFirst;
final IconData icon;
final Color iconColor;
final String name;
final String subtitle;
final Widget? trailing;
const ReorderablePriorityItem({
super.key,
required this.index,
required this.isFirst,
required this.icon,
required this.iconColor,
required this.name,
required this.subtitle,
this.trailing,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final backgroundColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
colorScheme.surface,
)
: colorScheme.surfaceContainerHigh;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(16),
child: ReorderableDragStartListener(
index: index,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: isFirst
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${index + 1}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isFirst
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
),
),
const SizedBox(width: 16),
Icon(icon, color: iconColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (trailing != null) ...[trailing!, const SizedBox(width: 4)],
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
],
),
),
),
),
);
}
}