feat(ui): add shared widgets, utils, and models for deduplicated screens

Single homes for logic that was copy-pasted across screens: the settings
collapsing header, album detail header (online screen's design as the
reference), selection pill button + bottom-bar chrome, disc separator
chip, error card, cover URL upgrade, byte/clock formatting, duration
extraction, UnifiedLibraryItem (moved out of the queue_tab library so
other screens can import it), and the batch convert/ReplayGain engine
keyed on it.
This commit is contained in:
zarzet
2026-07-11 16:34:16 +07:00
parent 49ecfe261a
commit 9580fafe4f
11 changed files with 1508 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
import 'dart:ui';
import 'package:flutter/material.dart';
/// Collapsing album-detail header shared by the album, local-album, and
/// downloaded-album screens: full-bleed [background] (optionally blurred and
/// scrimmed), bottom gradient, centered square cover, title, optional
/// subtitle/meta/actions rows, and a circular back button. Content fades out
/// below 30% expansion; the [title] fades into the toolbar instead.
class AlbumDetailHeader extends StatelessWidget {
const AlbumDetailHeader({
super.key,
required this.title,
required this.expandedHeight,
required this.showTitleInAppBar,
required this.background,
this.blurAndScrimBackground = true,
this.coverBuilder,
this.subtitle,
this.meta,
this.actions,
this.appBarActions,
this.backgroundColor,
});
final String title;
final double expandedHeight;
final bool showTitleInAppBar;
/// Full-bleed background content (cover image, motion banner, placeholder).
final Widget background;
/// Blur the background and dim it 35%; disable for motion banners and
/// placeholder backgrounds.
final bool blurAndScrimBackground;
/// Builds the centered square cover for the given side length; null hides
/// the cover block (the header wraps it in the shared shadow + rounding).
final Widget Function(BuildContext context, double coverSize)? coverBuilder;
/// Artist line under the title (plain text or a clickable artist widget).
final Widget? subtitle;
/// "•"-joined meta row under the subtitle.
final Widget? meta;
/// Action row (play/shuffle, download-all, ...) under the meta row.
final Widget? actions;
/// Extra toolbar actions (top-right).
final List<Widget>? appBarActions;
final Color? backgroundColor;
/// Shrinks long titles so up to three lines fit the header.
double _titleFontSize() {
final length = title.trim().length;
if (length > 45) return 18;
if (length > 30) return 21;
return 24;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: backgroundColor ?? colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: showTitleInAppBar ? 1.0 : 0.0,
child: Text(
title,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Stack(
fit: StackFit.expand,
children: [
if (blurAndScrimBackground)
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: background,
)
else
background,
if (blurAndScrimBackground)
Container(color: Colors.black.withValues(alpha: 0.35)),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: expandedHeight * 0.65,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.85),
],
),
),
),
),
Positioned(
left: 20,
right: 20,
bottom: 40,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (coverBuilder != null) ...[
Builder(
builder: (context) {
final coverSize = (constraints.maxWidth * 0.5)
.clamp(150.0, 210.0)
.toDouble();
return Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(
alpha: 0.45,
),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: coverBuilder!(context, coverSize),
),
);
},
),
const SizedBox(height: 20),
],
Text(
title,
style: TextStyle(
color: Colors.white,
fontSize: _titleFontSize(),
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[
const SizedBox(height: 6),
subtitle!,
],
if (meta != null) ...[
const SizedBox(height: 12),
meta!,
],
if (actions != null) ...[
const SizedBox(height: 16),
actions!,
],
],
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground],
);
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
actions: appBarActions,
);
}
}
/// White "play" pill + translucent shuffle circle used by the local and
/// downloaded album headers.
class AlbumPlayActions extends StatelessWidget {
const AlbumPlayActions({
super.key,
required this.playLabel,
required this.shuffleTooltip,
required this.onPlay,
required this.onShuffle,
});
final String playLabel;
final String shuffleTooltip;
final VoidCallback onPlay;
final VoidCallback onShuffle;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: FilledButton.icon(
onPressed: onPlay,
icon: const Icon(Icons.play_arrow, size: 20),
label: Text(playLabel, maxLines: 1, overflow: TextOverflow.ellipsis),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
),
const SizedBox(width: 12),
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: IconButton(
tooltip: shuffleTooltip,
onPressed: onShuffle,
icon: const Icon(Icons.shuffle, color: Colors.white),
),
),
],
);
}
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// "Disc N" chip with a trailing hairline, shown between disc groups in
/// album track lists.
class DiscSeparatorChip extends StatelessWidget {
const DiscSeparatorChip({super.key, required this.discNumber});
final int discNumber;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.album,
size: 16,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6),
Text(
context.l10n.downloadedAlbumDiscHeader(discNumber),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
height: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
],
),
);
}
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Error card shown by detail screens: a dedicated rate-limit presentation
/// when the message looks like an HTTP 429, otherwise a generic error row.
class ErrorCard extends StatelessWidget {
const ErrorCard({super.key, required this.error, required this.colorScheme});
final String error;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
final isRateLimit =
error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
if (isRateLimit) {
return Card(
elevation: 0,
color: colorScheme.errorContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.errorRateLimited,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
context.l10n.errorRateLimitedMessage,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
),
),
],
),
),
],
),
),
);
}
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(error, style: TextStyle(color: colorScheme.error)),
),
],
),
),
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
/// Icon+label pill used in selection-mode bottom bars. Disabled state dims
/// both the fill and the content to 50% alpha.
class SelectionActionButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback? onPressed;
final ColorScheme colorScheme;
const SelectionActionButton({
super.key,
required this.icon,
required this.label,
required this.onPressed,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
final isDisabled = onPressed == null;
return Material(
color: isDisabled
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 18,
color: isDisabled
? colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDisabled
? colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
: colorScheme.onSecondaryContainer,
),
),
),
],
),
),
),
);
}
}
+119
View File
@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Shared chrome for the multi-select bottom bar: rounded surface, drag
/// handle, close button, "N selected" header and select-all toggle.
/// The screen-specific action buttons go in [children].
class SelectionBottomBar extends StatelessWidget {
const SelectionBottomBar({
super.key,
required this.selectedCount,
required this.allSelected,
required this.onClose,
required this.onToggleSelectAll,
required this.bottomPadding,
required this.children,
});
final int selectedCount;
final bool allSelected;
final VoidCallback onClose;
final VoidCallback onToggleSelectAll;
final double bottomPadding;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 12,
offset: const Offset(0, -4),
),
],
),
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
Row(
children: [
IconButton.filledTonal(
onPressed: onClose,
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.selectionSelected(selectedCount),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected
? context.l10n.selectionAllSelected
: context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
TextButton.icon(
onPressed: onToggleSelectAll,
icon: Icon(
allSelected ? Icons.deselect : Icons.select_all,
size: 20,
),
label: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
),
],
),
const SizedBox(height: 12),
...children,
],
),
),
),
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
/// The collapsing header shared by settings-style pages: a pinned
/// [SliverAppBar] whose title slides toward the leading edge and shrinks
/// from 28 to 20 logical pixels as the header collapses.
class SettingsSliverAppBar extends StatelessWidget {
const SettingsSliverAppBar({
super.key,
required this.title,
this.actions,
this.leading,
});
final String title;
final List<Widget>? actions;
/// Defaults to a back button popping the current route.
final Widget? leading;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
return SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: leading ??
IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: actions,
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) / (maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
title,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
);
}
}