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
+236 -357
View File
@@ -517,15 +517,23 @@ class _CollectionItemWidget extends StatelessWidget {
}
}
/// Widget for displaying artist items from default search (Deezer/Spotify)
class _SearchArtistItemWidget extends StatelessWidget {
final SearchArtist artist;
/// Generic row for artist/album/playlist results from default search
/// (Deezer/Spotify); cover shape, fallback icon, and subtitle vary per type.
class _SearchResultRowItem extends StatelessWidget {
final String? imageUrl;
final double coverBorderRadius;
final IconData fallbackIcon;
final String title;
final Widget subtitle;
final bool showDivider;
final VoidCallback onTap;
const _SearchArtistItemWidget({
super.key,
required this.artist,
const _SearchResultRowItem({
required this.imageUrl,
required this.coverBorderRadius,
required this.fallbackIcon,
required this.title,
required this.subtitle,
required this.showDivider,
required this.onTap,
});
@@ -534,9 +542,9 @@ class _SearchArtistItemWidget extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final hasValidImage =
artist.imageUrl != null &&
artist.imageUrl!.isNotEmpty &&
Uri.tryParse(artist.imageUrl!)?.hasAuthority == true;
imageUrl != null &&
imageUrl!.isNotEmpty &&
Uri.tryParse(imageUrl!)?.hasAuthority == true;
return Column(
mainAxisSize: MainAxisSize.min,
@@ -550,10 +558,10 @@ class _SearchArtistItemWidget extends StatelessWidget {
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(28),
borderRadius: BorderRadius.circular(coverBorderRadius),
child: hasValidImage
? CachedCoverImage(
imageUrl: artist.imageUrl!,
imageUrl: imageUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
@@ -563,7 +571,7 @@ class _SearchArtistItemWidget extends StatelessWidget {
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.person,
fallbackIcon,
color: colorScheme.onSurfaceVariant,
),
),
@@ -574,7 +582,7 @@ class _SearchArtistItemWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
artist.name,
title,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
@@ -582,14 +590,7 @@ class _SearchArtistItemWidget extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
context.l10n.recentTypeArtist,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle,
],
),
),
@@ -615,6 +616,41 @@ class _SearchArtistItemWidget extends StatelessWidget {
}
}
/// Widget for displaying artist items from default search (Deezer/Spotify)
class _SearchArtistItemWidget extends StatelessWidget {
final SearchArtist artist;
final bool showDivider;
final VoidCallback onTap;
const _SearchArtistItemWidget({
super.key,
required this.artist,
required this.showDivider,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return _SearchResultRowItem(
imageUrl: artist.imageUrl,
coverBorderRadius: 28,
fallbackIcon: Icons.person,
title: artist.name,
subtitle: Text(
context.l10n.recentTypeArtist,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
showDivider: showDivider,
onTap: onTap,
);
}
}
/// Widget for displaying album items from default search (Deezer/Spotify)
class _SearchAlbumItemWidget extends StatelessWidget {
final SearchAlbum album;
@@ -631,87 +667,24 @@ class _SearchAlbumItemWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final hasValidImage =
album.imageUrl != null &&
album.imageUrl!.isNotEmpty &&
Uri.tryParse(album.imageUrl!)?.hasAuthority == true;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: hasValidImage
? CachedCoverImage(
imageUrl: album.imageUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
)
: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
album.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
ClickableArtistName(
artistName: album.artists.isNotEmpty
? album.artists
: context.l10n.recentTypeAlbum,
coverUrl: album.imageUrl,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
size: 24,
),
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 80,
endIndent: 12,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
return _SearchResultRowItem(
imageUrl: album.imageUrl,
coverBorderRadius: 10,
fallbackIcon: Icons.album,
title: album.name,
subtitle: ClickableArtistName(
artistName: album.artists.isNotEmpty
? album.artists
: context.l10n.recentTypeAlbum,
coverUrl: album.imageUrl,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
showDivider: showDivider,
onTap: onTap,
);
}
}
@@ -732,86 +705,23 @@ class _SearchPlaylistItemWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final hasValidImage =
playlist.imageUrl != null &&
playlist.imageUrl!.isNotEmpty &&
Uri.tryParse(playlist.imageUrl!)?.hasAuthority == true;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: hasValidImage
? CachedCoverImage(
imageUrl: playlist.imageUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
)
: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.playlist_play,
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
playlist.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
playlist.owner.isNotEmpty
? playlist.owner
: context.l10n.recentTypePlaylist,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
size: 24,
),
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 80,
endIndent: 12,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
return _SearchResultRowItem(
imageUrl: playlist.imageUrl,
coverBorderRadius: 10,
fallbackIcon: Icons.playlist_play,
title: playlist.name,
subtitle: Text(
playlist.owner.isNotEmpty
? playlist.owner
: context.l10n.recentTypePlaylist,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
showDivider: showDivider,
onTap: onTap,
);
}
}
@@ -935,6 +845,71 @@ class _DownloadedOrRemoteCoverState extends State<_DownloadedOrRemoteCover> {
}
}
/// Legacy `duration_ms` parsing used by extension album/playlist/artist track
/// parsing, kept separate from [extractDurationMs] which also falls back to
/// a `duration` (seconds) field these call sites intentionally ignore.
int _legacyTrackDurationMs(Map<String, dynamic> data) {
final durationValue = data['duration_ms'];
if (durationValue is int) return durationValue;
if (durationValue is double) return durationValue.toInt();
return 0;
}
/// Prefers the track's own cover, falling back to the parent
/// album/playlist cover, without URL validation.
String? _resolveTrackCoverUrl(String? trackCover, String? fallbackCover) {
if (trackCover != null && trackCover.isNotEmpty) return trackCover;
return fallbackCover;
}
/// Shared loading/error+retry scaffold for extension album/playlist/artist
/// detail screens.
class _LoadingOrErrorScaffold extends StatelessWidget {
final String title;
final bool isLoading;
final String? error;
final Widget loadingBody;
final VoidCallback onRetry;
const _LoadingOrErrorScaffold({
required this.title,
required this.isLoading,
required this.error,
required this.loadingBody,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
if (isLoading) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: loadingBody,
);
}
return Scaffold(
appBar: AppBar(title: Text(title)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: onRetry,
child: Text(context.l10n.dialogRetry),
),
],
),
),
);
}
}
class ExtensionAlbumScreen extends ConsumerStatefulWidget {
final String extensionId;
final String albumId;
@@ -1059,86 +1034,48 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
String? albumTypeFallback,
int? totalTracksFallback,
}) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
final base = Track.fromBackendMap(data, source: widget.extensionId);
return Track(
id: (data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
name: base.name,
artistName: base.artistName,
albumName: (data['album_name'] ?? widget.albumName).toString(),
albumArtist: normalizeOptionalString(data['album_artist']?.toString()),
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
albumId: data['album_id']?.toString() ?? widget.albumId,
coverUrl: _resolveCoverUrl(
artistId: base.artistId ?? _artistId,
albumId: base.albumId ?? widget.albumId,
coverUrl: _resolveTrackCoverUrl(
data['cover_url']?.toString(),
widget.coverUrl,
),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(),
albumType:
normalizeOptionalString(data['album_type']?.toString()) ??
albumTypeFallback ??
_albumType,
totalTracks:
data['total_tracks'] as int? ??
totalTracksFallback ??
_albumTotalTracks,
composer: data['composer']?.toString(),
source: widget.extensionId,
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
isrc: base.isrc,
duration: (_legacyTrackDurationMs(data) / 1000).round(),
trackNumber: base.trackNumber,
discNumber: base.discNumber,
totalDiscs: base.totalDiscs,
releaseDate: base.releaseDate,
albumType: base.albumType ?? albumTypeFallback ?? _albumType,
totalTracks: base.totalTracks ?? totalTracksFallback ?? _albumTotalTracks,
composer: base.composer,
source: base.source,
audioQuality: base.audioQuality,
audioModes: base.audioModes,
previewUrl: base.previewUrl,
explicit: base.explicit,
);
}
String? _resolveCoverUrl(String? trackCover, String? albumCover) {
if (trackCover != null && trackCover.isNotEmpty) return trackCover;
return albumCover;
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
body: const AlbumTrackListSkeleton(
if (_isLoading || _error != null) {
return _LoadingOrErrorScaffold(
title: widget.albumName,
isLoading: _isLoading,
error: _error,
loadingBody: const AlbumTrackListSkeleton(
itemCount: 10,
showCoverHeader: true,
),
);
}
if (_error != null) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _fetchTracks,
child: Text(context.l10n.dialogRetry),
),
],
),
),
onRetry: _fetchTracks,
);
}
@@ -1239,74 +1176,46 @@ class _ExtensionPlaylistScreenState
}
Track _parseTrack(Map<String, dynamic> data) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
final base = Track.fromBackendMap(data, source: widget.extensionId);
return Track(
id: (data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
name: base.name,
artistName: base.artistName,
albumName: (data['album_name'] ?? '').toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: _resolveCoverUrl(
artistId: base.artistId,
albumId: base.albumId,
coverUrl: _resolveTrackCoverUrl(
data['cover_url']?.toString(),
widget.coverUrl,
),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
source: widget.extensionId,
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
isrc: base.isrc,
duration: (_legacyTrackDurationMs(data) / 1000).round(),
trackNumber: base.trackNumber,
discNumber: base.discNumber,
totalDiscs: base.totalDiscs,
releaseDate: base.releaseDate,
totalTracks: base.totalTracks,
composer: base.composer,
source: base.source,
audioQuality: base.audioQuality,
audioModes: base.audioModes,
previewUrl: base.previewUrl,
explicit: base.explicit,
);
}
String? _resolveCoverUrl(String? trackCover, String? playlistCover) {
if (trackCover != null && trackCover.isNotEmpty) return trackCover;
return playlistCover;
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: Text(widget.playlistName)),
body: const TrackListSkeleton(itemCount: 8, showCoverHeader: true),
);
}
if (_error != null) {
return Scaffold(
appBar: AppBar(title: Text(widget.playlistName)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _fetchTracks,
child: Text(context.l10n.dialogRetry),
),
],
),
if (_isLoading || _error != null) {
return _LoadingOrErrorScaffold(
title: widget.playlistName,
isLoading: _isLoading,
error: _error,
loadingBody: const TrackListSkeleton(
itemCount: 8,
showCoverHeader: true,
),
onRetry: _fetchTracks,
);
}
@@ -1427,68 +1336,38 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
}
Track _parseTrack(Map<String, dynamic> data) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
final base = Track.fromBackendMap(data);
return Track(
id: (data['id'] ?? data['spotify_id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ??
widget.artistId,
albumId: data['album_id']?.toString(),
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
name: base.name,
artistName: base.artistName,
albumName: base.albumName,
albumArtist: base.albumArtist,
artistId: base.artistId ?? widget.artistId,
albumId: base.albumId,
coverUrl: base.coverUrl,
isrc: base.isrc,
duration: (_legacyTrackDurationMs(data) / 1000).round(),
trackNumber: base.trackNumber,
discNumber: base.discNumber,
totalDiscs: base.totalDiscs,
releaseDate: base.releaseDate,
totalTracks: base.totalTracks,
composer: base.composer,
source: (data['provider_id'] ?? widget.extensionId).toString(),
previewUrl: data['preview_url']?.toString(),
previewUrl: base.previewUrl,
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: Text(widget.artistName)),
body: const ArtistScreenSkeleton(),
);
}
if (_error != null) {
return Scaffold(
appBar: AppBar(title: Text(widget.artistName)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _fetchArtist,
child: Text(context.l10n.dialogRetry),
),
],
),
),
if (_isLoading || _error != null) {
return _LoadingOrErrorScaffold(
title: widget.artistName,
isLoading: _isLoading,
error: _error,
loadingBody: const ArtistScreenSkeleton(),
onRetry: _fetchArtist,
);
}
+20 -87
View File
@@ -1,9 +1,9 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
@@ -14,7 +14,6 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/cover_art_utils.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -105,10 +104,6 @@ class _LibraryTracksFolderScreenState
return null;
}
bool _isCoverLocalPath(String url) {
return !url.startsWith('http://') && !url.startsWith('https://');
}
void _enterSelectionMode(String key) {
HapticFeedback.mediumImpact();
setState(() {
@@ -555,31 +550,15 @@ class _LibraryTracksFolderScreenState
errorBuilder: (_, _, _) => coverFallback,
)
: hasCoverUrl
? _isCoverLocalPath(coverUrl)
? Image.file(
File(coverUrl),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return Container(color: colorScheme.surface);
},
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
)
: CachedNetworkImage(
imageUrl: highResCoverUrl(coverUrl) ?? coverUrl,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
? LocalOrNetworkCoverImage(
url: coverUrl,
fit: BoxFit.cover,
localCacheWidth: cacheWidth,
networkCacheWidth: cacheWidth,
fadeInDuration: Duration.zero,
urlTransform: (u) => highResCoverUrl(u) ?? u,
placeholder: (_) => Container(color: colorScheme.surface),
)
: coverFallback;
return AlbumDetailHeader(
@@ -603,27 +582,16 @@ class _LibraryTracksFolderScreenState
errorBuilder: (_, _, _) => squarePlaceholder(),
);
}
if (hasCoverUrl && _isCoverLocalPath(coverUrl)) {
return Image.file(
File(coverUrl),
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
cacheWidth: cacheWidth,
gaplessPlayback: true,
errorBuilder: (_, _, _) => squarePlaceholder(),
);
}
if (hasCoverUrl) {
return CachedNetworkImage(
imageUrl: highResCoverUrl(coverUrl) ?? coverUrl,
return LocalOrNetworkCoverImage(
url: coverUrl,
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => squarePlaceholder(),
errorWidget: (_, _, _) => squarePlaceholder(),
localCacheWidth: cacheWidth,
networkCacheWidth: cacheWidth,
urlTransform: (u) => highResCoverUrl(u) ?? u,
placeholder: (_) => squarePlaceholder(),
);
}
return squarePlaceholder();
@@ -1229,8 +1197,6 @@ class _CollectionTrackTile extends ConsumerWidget {
}
Widget _buildTrackCover(BuildContext context, String coverUrl, double size) {
final isLocal =
!coverUrl.startsWith('http://') && !coverUrl.startsWith('https://');
final colorScheme = Theme.of(context).colorScheme;
Widget placeholder() => Container(
width: size,
@@ -1239,47 +1205,14 @@ class _CollectionTrackTile extends ConsumerWidget {
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
);
if (isLocal) {
return Image.file(
File(coverUrl),
width: size,
height: size,
fit: BoxFit.cover,
gaplessPlayback: true,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded) return child;
return SizedBox(
width: size,
height: size,
child: Stack(
fit: StackFit.expand,
children: [
placeholder(),
AnimatedOpacity(
opacity: frame == null ? 0.0 : 1.0,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
child: child,
),
],
),
);
},
errorBuilder: (_, _, _) => placeholder(),
);
}
return CachedNetworkImage(
imageUrl: coverUrl,
return LocalOrNetworkCoverImage(
url: coverUrl,
width: size,
height: size,
fit: BoxFit.cover,
memCacheWidth: (size * 2).toInt(),
cacheManager: CoverCacheManager.instance,
networkCacheWidth: (size * 2).toInt(),
fadeInDuration: const Duration(milliseconds: 180),
fadeOutDuration: const Duration(milliseconds: 90),
placeholder: (_, _) => placeholder(),
errorWidget: (_, _, _) => placeholder(),
placeholder: (_) => placeholder(),
);
}
+2 -54
View File
@@ -1,8 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -10,11 +8,11 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/lyrics_parser.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
final _log = AppLogger('NowPlaying');
@@ -259,7 +257,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
child: SizedBox(
width: artSize,
height: artSize,
child: _Artwork(
child: PlayerArtwork(
artUri: mediaItem.artUri?.toString(),
colorScheme: colorScheme,
cacheWidth:
@@ -1077,53 +1075,3 @@ class _QualityBadge extends StatelessWidget {
);
}
}
class _Artwork extends StatelessWidget {
final String? artUri;
final ColorScheme colorScheme;
final int? cacheWidth;
const _Artwork({
required this.artUri,
required this.colorScheme,
this.cacheWidth,
});
@override
Widget build(BuildContext context) {
final placeholder = Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 40,
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;
}
}
+6 -27
View File
@@ -424,36 +424,15 @@ extension _QueueTabNavigation on _QueueTabState {
if (firstCoverUrl != null) {
// Guard against local file paths that may have been stored as coverUrl
final isLocalPath =
!firstCoverUrl.startsWith('http://') &&
!firstCoverUrl.startsWith('https://');
if (isLocalPath) {
return ClipRRect(
borderRadius: borderRadius,
child: Image.file(
File(firstCoverUrl),
width: size,
height: size,
fit: BoxFit.cover,
cacheWidth: cacheExtent,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child;
return placeholder;
},
errorBuilder: (_, _, _) => placeholder,
),
);
}
return CachedCoverImage(
imageUrl: firstCoverUrl,
return LocalOrNetworkCoverImage(
url: firstCoverUrl,
width: size,
height: size,
memCacheWidth: cacheExtent,
borderRadius: borderRadius,
placeholder: (_, _) => placeholder,
errorWidget: (_, _, _) => placeholder,
localCacheWidth: cacheExtent,
networkCacheWidth: cacheExtent,
fadeInDuration: Duration.zero,
placeholder: (_) => placeholder,
);
}
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/discard_changes_dialog.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart';
@@ -57,7 +58,7 @@ class _DownloadFallbackExtensionsPageState
canPop: !_hasChanges,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _confirmDiscard(context);
final shouldPop = await showDiscardChangesDialog(context);
if (shouldPop && context.mounted) {
Navigator.pop(context);
}
@@ -72,7 +73,7 @@ class _DownloadFallbackExtensionsPageState
icon: const Icon(Icons.arrow_back),
onPressed: () async {
if (_hasChanges) {
final shouldPop = await _confirmDiscard(context);
final shouldPop = await showDiscardChangesDialog(context);
if (shouldPop && context.mounted) {
Navigator.pop(context);
}
@@ -172,27 +173,6 @@ class _DownloadFallbackExtensionsPageState
);
}
Future<bool> _confirmDiscard(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogDiscardChanges),
content: Text(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;
}
void _saveChanges() {
final allExtensionIds = _extensions
.map((extension) => extension.id)
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/discard_changes_dialog.dart';
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
import 'package:spotiflac_android/widgets/reorderable_priority_item.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/constants/music_services.dart';
@@ -66,7 +68,10 @@ class _LyricsProviderPriorityPageState
description: context.l10n.lyricsProvidersDescription,
infoText: context.l10n.lyricsProvidersInfoText,
onSave: _saveChanges,
onConfirmDiscard: _confirmDiscard,
onConfirmDiscard: (ctx) => showDiscardChangesDialog(
ctx,
content: ctx.l10n.lyricsProvidersDiscardContent,
),
slivers: [
if (_enabledProviders.isNotEmpty)
SliverToBoxAdapter(
@@ -84,13 +89,23 @@ class _LyricsProviderPriorityPageState
itemBuilder: (context, index) {
final id = _enabledProviders[index];
final info = _getLyricsProviderInfo(id, context);
return _EnabledProviderItem(
return ReorderablePriorityItem(
key: ValueKey(id),
providerId: id,
info: info,
index: index,
isFirst: index == 0,
onToggle: () => _disableProvider(id),
icon: info.icon,
iconColor: Theme.of(context).colorScheme.primary,
name: info.name,
subtitle: info.description,
trailing: SizedBox(
height: 32,
child: FittedBox(
child: Switch(
value: true,
onChanged: (_) => _disableProvider(id),
),
),
),
);
},
onReorderItem: (oldIndex, newIndex) {
@@ -160,27 +175,6 @@ class _LyricsProviderPriorityPageState
).showSnackBar(SnackBar(content: Text(context.l10n.lyricsProvidersSaved)));
}
Future<bool> _confirmDiscard(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogDiscardChanges),
content: Text(context.l10n.lyricsProvidersDiscardContent),
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;
}
static _LyricsProviderInfo _getLyricsProviderInfo(
String id,
BuildContext context,
@@ -262,105 +256,6 @@ class _LyricsProviderPriorityPageState
}
}
class _EnabledProviderItem extends StatelessWidget {
final String providerId;
final _LyricsProviderInfo info;
final int index;
final bool isFirst;
final VoidCallback onToggle;
const _EnabledProviderItem({
super.key,
required this.providerId,
required this.info,
required this.index,
required this.isFirst,
required this.onToggle,
});
@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(info.icon, color: colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
info.name,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
info.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
SizedBox(
height: 32,
child: FittedBox(
child: Switch(value: true, onChanged: (_) => onToggle()),
),
),
const SizedBox(width: 4),
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
],
),
),
),
),
);
}
}
class _DisabledProviderItem extends StatelessWidget {
final String providerId;
final _LyricsProviderInfo info;
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/widgets/discard_changes_dialog.dart';
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
import 'package:spotiflac_android/widgets/reorderable_priority_item.dart';
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
const MetadataProviderPriorityPage({super.key});
@@ -52,7 +54,7 @@ class _MetadataProviderPriorityPageState
infoText: context.l10n.metadataProviderPriorityInfo,
saveLabel: context.l10n.dialogSave,
onSave: _saveChanges,
onConfirmDiscard: _confirmDiscard,
onConfirmDiscard: showDiscardChangesDialog,
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -60,17 +62,19 @@ class _MetadataProviderPriorityPageState
itemCount: _providers.length,
itemBuilder: (context, index) {
final provider = _providers[index];
return _MetadataProviderItem(
final extension = ref
.read(extensionProvider)
.extensions
.where((ext) => ext.id == provider)
.firstOrNull;
return ReorderablePriorityItem(
key: ValueKey(provider),
provider: provider,
index: index,
isFirst: index == 0,
isLast: index == _providers.length - 1,
extension: ref
.read(extensionProvider)
.extensions
.where((ext) => ext.id == provider)
.firstOrNull,
icon: Icons.extension,
iconColor: Theme.of(context).colorScheme.secondary,
name: extension?.displayName ?? provider,
subtitle: context.l10n.providerExtension,
);
},
onReorderItem: (oldIndex, newIndex) {
@@ -86,27 +90,6 @@ class _MetadataProviderPriorityPageState
);
}
Future<bool> _confirmDiscard(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogDiscardChanges),
content: Text(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;
}
Future<void> _saveChanges() async {
await ref
.read(extensionProvider.notifier)
@@ -120,121 +103,3 @@ class _MetadataProviderPriorityPageState
);
}
}
class _MetadataProviderItem extends StatelessWidget {
final String provider;
final int index;
final bool isFirst;
final bool isLast;
final Extension? extension;
const _MetadataProviderItem({
super.key,
required this.provider,
required this.index,
required this.isFirst,
required this.isLast,
this.extension,
});
@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;
final info = _getProviderInfo(context, provider, extension);
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(info.icon, color: colorScheme.secondary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
info.name,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
info.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
],
),
),
),
),
);
}
_MetadataProviderInfo _getProviderInfo(
BuildContext context,
String provider,
Extension? extension,
) {
return _MetadataProviderInfo(
name: extension?.displayName ?? provider,
icon: Icons.extension,
description: context.l10n.providerExtension,
);
}
}
class _MetadataProviderInfo {
final String name;
final IconData icon;
final String description;
_MetadataProviderInfo({
required this.name,
required this.icon,
required this.description,
});
}
+42 -245
View File
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart';
import 'package:spotiflac_android/widgets/discard_changes_dialog.dart';
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
import 'package:spotiflac_android/widgets/reorderable_priority_item.dart';
class ProviderPriorityPage extends ConsumerStatefulWidget {
const ProviderPriorityPage({super.key});
@@ -43,146 +45,49 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return PopScope(
canPop: !_hasChanges,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _confirmDiscard(context);
if (shouldPop && context.mounted) {
Navigator.pop(context);
}
},
child: Scaffold(
body: CustomScrollView(
slivers: [
SettingsSliverAppBar(
title: context.l10n.providerPriorityTitle,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () async {
if (_hasChanges) {
final shouldPop = await _confirmDiscard(context);
if (shouldPop && context.mounted) {
Navigator.pop(context);
}
} else {
Navigator.pop(context);
}
},
),
actions: [
if (_hasChanges)
TextButton(
onPressed: _saveChanges,
child: Text(context.l10n.dialogSave),
),
],
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
context.l10n.providerPriorityDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverReorderableList(
itemCount: _providers.length,
itemBuilder: (context, index) {
final provider = _providers[index];
return _ProviderItem(
key: ValueKey(provider),
provider: provider,
index: index,
isFirst: index == 0,
isLast: index == _providers.length - 1,
extension: ref
.read(extensionProvider)
.extensions
.where((ext) => ext.id == provider)
.firstOrNull,
);
},
onReorderItem: (oldIndex, newIndex) {
setState(() {
final item = _providers.removeAt(oldIndex);
_providers.insert(newIndex, item);
_hasChanges = true;
});
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.tertiary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.providerPriorityInfo,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
),
],
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
return PrioritySettingsScaffold(
hasChanges: _hasChanges,
title: context.l10n.providerPriorityTitle,
description: context.l10n.providerPriorityDescription,
descriptionPadding: const EdgeInsets.all(16),
infoText: context.l10n.providerPriorityInfo,
onSave: _saveChanges,
onConfirmDiscard: showDiscardChangesDialog,
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverReorderableList(
itemCount: _providers.length,
itemBuilder: (context, index) {
final provider = _providers[index];
final extension = ref
.read(extensionProvider)
.extensions
.where((ext) => ext.id == provider)
.firstOrNull;
return ReorderablePriorityItem(
key: ValueKey(provider),
index: index,
isFirst: index == 0,
icon: Icons.extension,
iconColor: Theme.of(context).colorScheme.secondary,
name: extension?.displayName ?? provider,
subtitle: context.l10n.providerExtension,
);
},
onReorderItem: (oldIndex, newIndex) {
setState(() {
final item = _providers.removeAt(oldIndex);
_providers.insert(newIndex, item);
_hasChanges = true;
});
},
),
),
),
],
);
}
Future<bool> _confirmDiscard(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogDiscardChanges),
content: Text(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;
}
Future<void> _saveChanges() async {
await ref.read(extensionProvider.notifier).setProviderPriority(_providers);
if (!mounted) return;
@@ -194,111 +99,3 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
);
}
}
class _ProviderItem extends StatelessWidget {
final String provider;
final int index;
final bool isFirst;
final bool isLast;
final Extension? extension;
const _ProviderItem({
super.key,
required this.provider,
required this.index,
required this.isFirst,
required this.isLast,
this.extension,
});
@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;
final info = _getProviderInfo(provider, extension);
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(info.icon, color: colorScheme.secondary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
info.name,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
context.l10n.providerExtension,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
],
),
),
),
),
);
}
_ProviderInfo _getProviderInfo(String provider, Extension? extension) {
return _ProviderInfo(
name: extension?.displayName ?? provider,
icon: Icons.extension,
);
}
}
class _ProviderInfo {
final String name;
final IconData icon;
_ProviderInfo({required this.name, required this.icon});
}
+37 -446
View File
@@ -241,21 +241,7 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
formats.addAll(['AAC', 'MP3', 'Opus']);
}
String selectedFormat = formats.first;
String defaultBitrateForFormat(String format) {
if (format == 'Opus') return '128k';
if (format == 'AAC') return '256k';
return '320k';
}
String selectedBitrate = defaultBitrateForFormat(selectedFormat);
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
int? selectedMaxBitDepth;
int? selectedMaxSampleRate;
String selectedDither = 'none';
String selectedResampler = 'swr';
final bitDepthOptions = availableLosslessBitDepthOptions(bitDepth);
final sampleRateOptions = availableLosslessSampleRateOptions(sampleRate);
final labels = context.l10n.losslessConversionLabels;
showModalBottomSheet<void>(
context: context,
@@ -264,438 +250,43 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final labels = context.l10n.losslessConversionLabels;
final bitrates = ['128k', '192k', '256k', '320k'];
Widget card({required Widget child}) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: settingsGroupColor(context),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: child,
);
}
Widget sectionLabel(String text) {
return Padding(
padding: const EdgeInsets.only(left: 2, bottom: 12),
child: Text(
text,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
),
);
}
Widget choice({
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return Material(
color: selected
? colorScheme.primaryContainer
: colorScheme.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 11,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected
? Colors.transparent
: colorScheme.outlineVariant.withValues(alpha: 0.6),
builder: (sheetContext) => BatchConvertSheet(
formats: formats,
title: context.l10n.trackConvertTitle,
subtitle: currentFormat,
sourceIsLossless: isLosslessSource,
sourceBitDepth: bitDepth,
sourceSampleRate: sampleRate,
confirmLabelBuilder:
(format, bitrate, isLosslessTarget, losslessQuality) {
return isLosslessTarget
? context.l10n.trackConvertActionLabelLossless(
currentFormat,
format,
losslessQualityLabel(
losslessQuality,
originalLabel: labels.original,
originalQualityLabel: labels.originalQuality,
),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: selected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface,
fontWeight: selected
? FontWeight.w600
: FontWeight.w500,
),
),
),
),
);
}
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
child: DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => SafeArea(
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 18),
Text(
context.l10n.trackConvertTitle,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
currentFormat,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 20),
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(
context.l10n.trackConvertTargetFormat,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: formats.map((format) {
return choice(
label: format,
selected: format == selectedFormat,
onTap: () {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
isLosslessConversionTarget(format);
if (!isLosslessTarget) {
selectedBitrate =
defaultBitrateForFormat(format);
} else {
selectedMaxBitDepth = null;
selectedMaxSampleRate = null;
selectedDither = 'none';
selectedResampler = 'swr';
}
});
},
);
}).toList(),
),
],
),
),
if (!isLosslessTarget)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(context.l10n.trackConvertBitrate),
Wrap(
spacing: 8,
runSpacing: 8,
children: bitrates.map((br) {
return choice(
label: br,
selected: br == selectedBitrate,
onTap: () => setSheetState(
() => selectedBitrate = br,
),
);
}).toList(),
),
],
),
),
if (isLosslessTarget && bitDepthOptions.isNotEmpty)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(
context.l10n.audioAnalysisBitDepth,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
choice(
label: losslessBitDepthLabel(
null,
originalLabel: labels.original,
),
selected: selectedMaxBitDepth == null,
onTap: () => setSheetState(() {
selectedMaxBitDepth = null;
selectedDither = 'none';
}),
),
...bitDepthOptions.map((depth) {
return choice(
label: losslessBitDepthLabel(
depth,
originalLabel: labels.original,
),
selected: depth == selectedMaxBitDepth,
onTap: () => setSheetState(
() => selectedMaxBitDepth = depth,
),
);
}),
],
),
],
),
),
if (isLosslessTarget && sampleRateOptions.isNotEmpty)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(
context.l10n.audioAnalysisSampleRate,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
choice(
label: losslessSampleRateLabel(
null,
originalLabel: labels.original,
),
selected: selectedMaxSampleRate == null,
onTap: () => setSheetState(() {
selectedMaxSampleRate = null;
selectedResampler = 'swr';
}),
),
...sampleRateOptions.map((rate) {
return choice(
label: losslessSampleRateLabel(
rate,
originalLabel: labels.original,
),
selected: rate == selectedMaxSampleRate,
onTap: () => setSheetState(
() => selectedMaxSampleRate = rate,
),
);
}),
],
),
],
),
),
if (isLosslessTarget && selectedMaxBitDepth != null)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(
context.l10n.trackConvertDithering,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: losslessDitherOptions.map((mode) {
return choice(
label: context.l10n
.losslessDitherOptionLabel(mode),
selected: mode == selectedDither,
onTap: () => setSheetState(
() => selectedDither = mode,
),
);
}).toList(),
),
],
),
),
if (isLosslessTarget && selectedMaxSampleRate != null)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(
context.l10n.trackConvertResampler,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: losslessResamplerOptions.map((
mode,
) {
return choice(
label: context.l10n
.losslessResamplerOptionLabel(mode),
selected: mode == selectedResampler,
onTap: () => setSheetState(
() => selectedResampler = mode,
),
);
}).toList(),
),
],
),
),
if (isLosslessTarget && isLosslessSource)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(
Icons.verified,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
selectedMaxBitDepth == null &&
selectedMaxSampleRate == null
? context.l10n.trackConvertLosslessHint
: context.l10n
.trackConvertLosslessOutputWithCap(
losslessQualityLabel(
LosslessConversionQuality(
maxBitDepth:
selectedMaxBitDepth,
maxSampleRate:
selectedMaxSampleRate,
),
originalLabel:
labels.original,
originalQualityLabel:
labels.originalQuality,
),
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
),
],
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
Navigator.pop(context);
_confirmAndConvert(
context: this.context,
sourceFormat: currentFormat,
targetFormat: selectedFormat,
bitrate: selectedBitrate,
losslessQuality: LosslessConversionQuality(
maxBitDepth: selectedMaxBitDepth,
maxSampleRate: selectedMaxSampleRate,
),
losslessProcessing:
LosslessConversionProcessing(
dither: selectedDither,
resampler: selectedResampler,
),
);
},
icon: const Icon(Icons.swap_horiz),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
label: Text(
isLosslessTarget
? context.l10n
.trackConvertActionLabelLossless(
currentFormat,
selectedFormat,
losslessQualityLabel(
LosslessConversionQuality(
maxBitDepth: selectedMaxBitDepth,
maxSampleRate:
selectedMaxSampleRate,
),
originalLabel: labels.original,
originalQualityLabel:
labels.originalQuality,
),
)
: context.l10n.trackConvertActionLabelLossy(
currentFormat,
selectedFormat,
selectedBitrate,
),
),
),
),
],
),
),
),
),
);
},
);
},
)
: context.l10n.trackConvertActionLabelLossy(
currentFormat,
format,
bitrate,
);
},
onConvert: (format, bitrate, losslessQuality, losslessProcessing) {
Navigator.pop(sheetContext);
_confirmAndConvert(
context: this.context,
sourceFormat: currentFormat,
targetFormat: format,
bitrate: bitrate,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
},
),
);
}
+1
View File
@@ -29,6 +29,7 @@ import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/constants/music_services.dart';
+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),
],
),
),
),
),
);
}
}