mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
feat(ui): add Mornye glass theme and adaptive music player
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components. Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures. Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
This commit is contained in:
1 parent
1ebdac8645
commit
63e1a9b9c8
155 files changed
+15707
-2318
No files matched your search
@@ -1,8 +1,14 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
import 'package:spotiflac_android/theme/cover_palette.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_icons.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
|
||||
/// Collapsing album-detail header shared by the album, local-album, and
|
||||
@@ -32,6 +38,8 @@ class AlbumDetailHeader extends StatelessWidget {
|
||||
this.appBarTitle,
|
||||
this.leading,
|
||||
this.backgroundColor,
|
||||
this.immersive = false,
|
||||
this.squareArtwork = true,
|
||||
});
|
||||
|
||||
final String title;
|
||||
@@ -73,6 +81,10 @@ class AlbumDetailHeader extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
|
||||
final Color? backgroundColor;
|
||||
final bool immersive;
|
||||
|
||||
/// Ordinary cover art stays 1:1; provider-supplied banners may fill a header.
|
||||
final bool squareArtwork;
|
||||
|
||||
/// Shrinks long titles so up to three lines fit the header.
|
||||
double _titleFontSize() {
|
||||
@@ -84,14 +96,164 @@ class AlbumDetailHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (context.isMornye && (coverBuilder != null || immersive)) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return HeaderPalette(
|
||||
scheme: scheme,
|
||||
child: Builder(
|
||||
builder: (context) =>
|
||||
SliverMainAxisGroup(slivers: buildSlivers(context)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return CoverPaletteBuilder(
|
||||
imageSource: paletteSource,
|
||||
builder: (context, headerScheme) => _buildAppBar(context, headerScheme),
|
||||
);
|
||||
}
|
||||
|
||||
/// Keep the toolbar at the collection scroll-view level so it stays pinned
|
||||
/// after the dynamically sized artwork and metadata have scrolled away.
|
||||
List<Widget> buildSlivers(BuildContext context) {
|
||||
if (!context.isMornye || (coverBuilder == null && !immersive)) {
|
||||
return [this];
|
||||
}
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final edgeInset = detailHeaderEdgeInset(context);
|
||||
final artworkSize = MediaQuery.sizeOf(context).width.clamp(0.0, 440.0);
|
||||
// Keep the artwork and text in normal sliver flow: Dynamic Type and long
|
||||
// album names must grow the header instead of overflowing a fixed height.
|
||||
return [
|
||||
// Only the toolbar needs safe-area padding. Reading it on the caller's
|
||||
// context rebuilds all header slivers as the navigation bar folds.
|
||||
Builder(
|
||||
builder: (context) => SliverAppBar(
|
||||
pinned: true,
|
||||
expandedHeight: immersive
|
||||
? squareArtwork
|
||||
? artworkSize - MediaQuery.paddingOf(context).top
|
||||
: MediaQuery.sizeOf(context).width.clamp(280.0, 440.0)
|
||||
: null,
|
||||
backgroundColor: scheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: immersive
|
||||
? FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.pin,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (squareArtwork)
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: SizedBox.square(
|
||||
dimension: artworkSize,
|
||||
child: background,
|
||||
),
|
||||
)
|
||||
else
|
||||
background,
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0, 0.52, 1],
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.12),
|
||||
scheme.surface.withValues(alpha: 0),
|
||||
scheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
title: AnimatedOpacity(
|
||||
opacity: showTitleInAppBar || appBarTitle != null ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: Text(
|
||||
appBarTitle ?? title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
leadingWidth: kToolbarHeight + edgeInset,
|
||||
leading: Padding(
|
||||
padding: EdgeInsets.only(left: edgeInset),
|
||||
child:
|
||||
leading ??
|
||||
HeaderCircleButton(
|
||||
icon: CupertinoIcons.chevron_back,
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
actionsPadding: EdgeInsets.only(right: edgeInset),
|
||||
actions: appBarActions,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, immersive ? 0 : 20, 20, 28),
|
||||
child: Column(
|
||||
children: [
|
||||
if (!immersive)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.16),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) =>
|
||||
coverBuilder!(context, constraints.maxWidth),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!immersive) const SizedBox(height: 22),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[const SizedBox(height: 5), subtitle!],
|
||||
if (meta != null) ...[const SizedBox(height: 5), meta!],
|
||||
if (actions != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
child: actions!,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme headerScheme) {
|
||||
final tokens = context.tokens;
|
||||
final plainArtworkHeader = context.isMornye && coverBuilder != null;
|
||||
// iOS does not add horizontal safe-area padding in portrait. Give toolbar
|
||||
// controls and header content an explicit inset so circular actions do not
|
||||
// sit against the glass edge on either iPhone or iPad. Android retains its
|
||||
@@ -135,33 +297,36 @@ class AlbumDetailHeader extends StatelessWidget {
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (blurAndScrimBackground)
|
||||
if (plainArtworkHeader)
|
||||
ColoredBox(color: headerScheme.surface)
|
||||
else if (blurAndScrimBackground)
|
||||
ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
|
||||
child: background,
|
||||
)
|
||||
else
|
||||
background,
|
||||
if (blurAndScrimBackground)
|
||||
if (!plainArtworkHeader && blurAndScrimBackground)
|
||||
ColoredBox(color: scrimColor.withValues(alpha: 0.4)),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: expandedHeight * 0.65,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
scrimColor.withValues(alpha: 0),
|
||||
scrimColor.withValues(alpha: 0.92),
|
||||
],
|
||||
if (!plainArtworkHeader)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: expandedHeight * 0.65,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
scrimColor.withValues(alpha: 0),
|
||||
scrimColor.withValues(alpha: 0.92),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 20 + iosEdgeInset,
|
||||
right: 20 + iosEdgeInset,
|
||||
@@ -184,7 +349,9 @@ class AlbumDetailHeader extends StatelessWidget {
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
tokens.radiusControl,
|
||||
plainArtworkHeader
|
||||
? tokens.radiusCover
|
||||
: tokens.radiusControl,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
@@ -248,7 +415,9 @@ class AlbumDetailHeader extends StatelessWidget {
|
||||
leading ??
|
||||
IconButton.filledTonal(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
icon: Icon(
|
||||
context.isMornye ? Icons.chevron_left : Icons.arrow_back,
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: Size.square(tokens.minTouchTarget),
|
||||
backgroundColor: headerScheme.surfaceContainerHigh.withValues(
|
||||
@@ -285,6 +454,28 @@ class AlbumPlayActions extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.tokens;
|
||||
final scheme = HeaderPalette.of(context);
|
||||
if (context.isMornye) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: HeaderFilledButton(
|
||||
icon: CupertinoIcons.play_fill,
|
||||
label: playLabel,
|
||||
onPressed: onPlay,
|
||||
tonal: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _MornyeHeaderButton(
|
||||
icon: CupertinoIcons.shuffle,
|
||||
label: shuffleTooltip,
|
||||
onPressed: onShuffle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -329,16 +520,26 @@ class HeaderFilledButton extends StatelessWidget {
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.tonal = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool tonal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.tokens;
|
||||
final scheme = HeaderPalette.of(context);
|
||||
if (context.isMornye) {
|
||||
return _MornyeHeaderButton(
|
||||
icon: mornyeIconFor(icon),
|
||||
label: label,
|
||||
onPressed: onPressed,
|
||||
prominent: !tonal,
|
||||
);
|
||||
}
|
||||
return FilledButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: 18),
|
||||
@@ -357,13 +558,14 @@ class HeaderFilledButton extends StatelessWidget {
|
||||
|
||||
/// Circular header icon button (add-to-playlist, love-all, ...). Sized to the
|
||||
/// minimum touch target and tinted from the [HeaderPalette].
|
||||
class HeaderCircleButton extends StatelessWidget {
|
||||
class HeaderCircleButton extends ConsumerWidget {
|
||||
const HeaderCircleButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
this.iconColor,
|
||||
this.tonal = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
@@ -373,10 +575,62 @@ class HeaderCircleButton extends StatelessWidget {
|
||||
/// Overrides the palette foreground, e.g. to mark an active "loved" state.
|
||||
final Color? iconColor;
|
||||
|
||||
/// Uses the same translucent fill as the local album's Play/Shuffle pills.
|
||||
final bool tonal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tokens = context.tokens;
|
||||
final scheme = HeaderPalette.of(context);
|
||||
if (context.isMornye) {
|
||||
if (tonal) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: SizedBox.square(
|
||||
dimension: 44,
|
||||
child: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: scheme.onSurface.withValues(
|
||||
alpha: scheme.brightness == Brightness.dark ? 0.10 : 0.06,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: Icon(
|
||||
mornyeIconFor(icon),
|
||||
size: 22,
|
||||
color: onPressed == null
|
||||
? scheme.onSurfaceVariant
|
||||
: iconColor ?? scheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final blurEnabled =
|
||||
!ref.watch(lowEndDeviceProvider) ||
|
||||
ref.watch(backdropBlurEnabledProvider);
|
||||
final button = IconButton(
|
||||
onPressed: onPressed,
|
||||
tooltip: tooltip,
|
||||
icon: Icon(mornyeIconFor(icon), size: 22),
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size.square(44),
|
||||
foregroundColor: iconColor ?? scheme.onSurface,
|
||||
),
|
||||
);
|
||||
return Center(
|
||||
widthFactor: 1,
|
||||
child: MornyeGlass.navigation(
|
||||
radius: 24,
|
||||
tintColor: scheme.brightness == Brightness.dark
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
tintOpacity: scheme.brightness == Brightness.dark ? 0.45 : 0.55,
|
||||
blurEnabled: blurEnabled,
|
||||
child: button,
|
||||
),
|
||||
);
|
||||
}
|
||||
return IconButton.filledTonal(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: 22),
|
||||
@@ -390,6 +644,63 @@ class HeaderCircleButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MornyeHeaderButton extends StatelessWidget {
|
||||
const _MornyeHeaderButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.prominent = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool prominent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = HeaderPalette.of(context);
|
||||
final foreground = onPressed == null
|
||||
? scheme.onSurfaceVariant
|
||||
: prominent
|
||||
? scheme.onPrimary
|
||||
: scheme.primary;
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 50),
|
||||
child: CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
color: prominent
|
||||
? scheme.primary
|
||||
: scheme.onSurface.withValues(
|
||||
alpha: scheme.brightness == Brightness.dark ? 0.10 : 0.06,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: foreground),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "•"-joined row of [HeaderMetaItem]s shown under the header subtitle
|
||||
/// (track count, duration, quality, ...).
|
||||
class HeaderMetaRow extends StatelessWidget {
|
||||
@@ -436,7 +747,7 @@ class HeaderMetaItem extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = HeaderPalette.of(context);
|
||||
final textStyle = TextStyle(
|
||||
color: scheme.onSurface,
|
||||
color: context.isMornye ? scheme.onSurfaceVariant : scheme.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
import 'package:spotiflac_android/widgets/track_card.dart';
|
||||
@@ -58,10 +60,15 @@ class AlbumTrackTile extends StatelessWidget {
|
||||
trailing: IconButton(
|
||||
tooltip: context.l10n.tooltipPlay,
|
||||
onPressed: onPlay,
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
icon: Icon(
|
||||
context.isMornye ? CupertinoIcons.play_fill : Icons.play_arrow,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: Size.square(context.tokens.minTouchTarget),
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
backgroundColor: context.isMornye
|
||||
? Colors.transparent
|
||||
: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
|
||||
/// Wraps a child in a staggered fade-in + slide-up animation.
|
||||
///
|
||||
@@ -194,6 +195,24 @@ class TrackListSkeleton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (context.isMornye) {
|
||||
return ShimmerLoading(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
if (showCoverHeader)
|
||||
const _CollectionHeaderSkeleton(showSubtitle: false),
|
||||
for (var index = 0; index < itemCount; index++)
|
||||
_MornyeTrackSkeleton(
|
||||
numbered: false,
|
||||
index: index,
|
||||
showPreview: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ShimmerLoading(
|
||||
child: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
@@ -265,6 +284,23 @@ class AlbumTrackListSkeleton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (context.isMornye) {
|
||||
return ShimmerLoading(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
if (showCoverHeader) const _CollectionHeaderSkeleton(),
|
||||
for (var index = 0; index < itemCount; index++)
|
||||
_MornyeTrackSkeleton(
|
||||
numbered: true,
|
||||
index: index,
|
||||
showPreview: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ShimmerLoading(
|
||||
child: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
@@ -331,14 +367,87 @@ class AlbumTrackListSkeleton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Header skeleton matching the redesigned album/playlist header: a blurred
|
||||
/// backdrop block with a centered square cover, title/subtitle bars, a meta
|
||||
/// line (year + quality badges) and the action button row.
|
||||
/// Mirrors each theme's collection header, including Mornye's full-width
|
||||
/// square artwork and compact primary action between two circular controls.
|
||||
class _CollectionHeaderSkeleton extends StatelessWidget {
|
||||
const _CollectionHeaderSkeleton();
|
||||
const _CollectionHeaderSkeleton({this.showSubtitle = true});
|
||||
|
||||
final bool showSubtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (context.isMornye) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final coverSize = constraints.maxWidth.clamp(0.0, 440.0);
|
||||
return Column(
|
||||
children: [
|
||||
ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: [0, 0.52, 1],
|
||||
colors: [Colors.white, Colors.white, Colors.transparent],
|
||||
).createShader(bounds),
|
||||
child: SkeletonBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
borderRadius: 0,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 28),
|
||||
child: Column(
|
||||
children: [
|
||||
const FractionallySizedBox(
|
||||
widthFactor: 0.75,
|
||||
child: SkeletonBox(
|
||||
width: double.infinity,
|
||||
height: 28,
|
||||
borderRadius: 6,
|
||||
),
|
||||
),
|
||||
if (showSubtitle) ...[
|
||||
const SizedBox(height: 5),
|
||||
const FractionallySizedBox(
|
||||
widthFactor: 0.5,
|
||||
child: SkeletonBox(
|
||||
width: double.infinity,
|
||||
height: 24,
|
||||
borderRadius: 5,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 5),
|
||||
const SkeletonBox(width: 140, height: 16, borderRadius: 4),
|
||||
const SizedBox(height: 24),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 16,
|
||||
children: [
|
||||
SkeletonBox(width: 44, height: 44, borderRadius: 22),
|
||||
Flexible(
|
||||
child: SkeletonBox(
|
||||
width: 172,
|
||||
height: 50,
|
||||
borderRadius: 25,
|
||||
),
|
||||
),
|
||||
SkeletonBox(width: 44, height: 44, borderRadius: 22),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final coverSize = (screenWidth * 0.5).clamp(150.0, 210.0).toDouble();
|
||||
|
||||
@@ -403,6 +512,104 @@ class ArtistScreenSkeleton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (context.isMornye) {
|
||||
return ShimmerLoading(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showCoverHeader) ...[
|
||||
const SkeletonBox(
|
||||
width: double.infinity,
|
||||
height: 342,
|
||||
borderRadius: 0,
|
||||
),
|
||||
const Center(child: SkeletonBox(width: 220, height: 36)),
|
||||
const SizedBox(height: 16),
|
||||
const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 24,
|
||||
children: [
|
||||
SkeletonBox(width: 44, height: 44, borderRadius: 22),
|
||||
SkeletonBox(width: 60, height: 60, borderRadius: 30),
|
||||
SkeletonBox(width: 44, height: 44, borderRadius: 22),
|
||||
],
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Row(
|
||||
spacing: 14,
|
||||
children: [
|
||||
SkeletonBox(width: 76, height: 76, borderRadius: 5),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 6,
|
||||
children: [
|
||||
SkeletonBox(width: 38, height: 12, borderRadius: 4),
|
||||
FractionallySizedBox(
|
||||
widthFactor: 0.9,
|
||||
child: SkeletonBox(
|
||||
width: double.infinity,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
),
|
||||
),
|
||||
SkeletonBox(width: 62, height: 12, borderRadius: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
SkeletonBox(width: 10, height: 14, borderRadius: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showPopularSection) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 24, 20, 12),
|
||||
child: SkeletonBox(width: 110, height: 24, borderRadius: 4),
|
||||
),
|
||||
for (var index = 0; index < popularCount; index++)
|
||||
_MornyeTrackSkeleton(numbered: false, index: index),
|
||||
],
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 24, 20, 12),
|
||||
child: SkeletonBox(width: 120, height: 24, borderRadius: 4),
|
||||
),
|
||||
SizedBox(
|
||||
height: 208,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
itemCount: albumCount,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 12),
|
||||
itemBuilder: (_, index) => const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SkeletonBox(width: 156, height: 156, borderRadius: 8),
|
||||
SizedBox(height: 8),
|
||||
SkeletonBox(width: 120, height: 16, borderRadius: 4),
|
||||
SizedBox(height: 4),
|
||||
SkeletonBox(width: 45, height: 12, borderRadius: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
return ShimmerLoading(
|
||||
child: SingleChildScrollView(
|
||||
@@ -513,6 +720,77 @@ class ArtistScreenSkeleton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MornyeTrackSkeleton extends StatelessWidget {
|
||||
const _MornyeTrackSkeleton({
|
||||
required this.numbered,
|
||||
required this.index,
|
||||
this.showPreview = false,
|
||||
});
|
||||
|
||||
final bool numbered;
|
||||
final int index;
|
||||
final bool showPreview;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (numbered)
|
||||
const SizedBox(
|
||||
width: 32,
|
||||
child: Center(
|
||||
child: SkeletonBox(width: 14, height: 16, borderRadius: 4),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SkeletonBox(width: 48, height: 48, borderRadius: 4),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FractionallySizedBox(
|
||||
widthFactor: 0.7 + (index % 3) * 0.1,
|
||||
child: const SkeletonBox(
|
||||
width: double.infinity,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const FractionallySizedBox(
|
||||
widthFactor: 0.5,
|
||||
child: SkeletonBox(
|
||||
width: double.infinity,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
if (showPreview) ...[
|
||||
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
|
||||
const SizedBox(width: 24),
|
||||
],
|
||||
const SkeletonBox(width: 20, height: 6, borderRadius: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: numbered ? 64 : 80, right: 20),
|
||||
child: const Divider(height: 1, thickness: 0.5),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Home search skeleton – mimics filter chips + sectioned results
|
||||
/// (Artists section with rounded card items, Albums section, etc.)
|
||||
class HomeSearchSkeleton extends StatelessWidget {
|
||||
@@ -558,6 +836,50 @@ class HomeSearchSkeleton extends StatelessWidget {
|
||||
double headerWidth,
|
||||
int itemCount,
|
||||
) {
|
||||
final mornye = context.isMornye;
|
||||
final rows = List.generate(itemCount, (index) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: mornye ? 12 : 16,
|
||||
vertical: mornye ? 12 : 10,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SkeletonBox(
|
||||
width: mornye ? 56 : 48,
|
||||
height: mornye ? 56 : 48,
|
||||
borderRadius: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SkeletonBox(
|
||||
width: 100 + (index % 3) * 40,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SkeletonBox(
|
||||
width: 60 + (index % 2) * 25,
|
||||
height: 12,
|
||||
borderRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
final decoration = BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(mornye ? 24 : 20),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -571,50 +893,24 @@ class HomeSearchSkeleton extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
if (mornye)
|
||||
for (var index = 0; index < rows.length; index++)
|
||||
Container(
|
||||
margin: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
index == rows.length - 1 ? 0 : 8,
|
||||
),
|
||||
decoration: decoration,
|
||||
child: rows[index],
|
||||
)
|
||||
else
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: decoration,
|
||||
child: Column(children: rows),
|
||||
),
|
||||
child: Column(
|
||||
children: List.generate(itemCount, (index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SkeletonBox(width: 48, height: 48, borderRadius: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SkeletonBox(
|
||||
width: 100 + (index % 3) * 40,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SkeletonBox(
|
||||
width: 60 + (index % 2) * 25,
|
||||
height: 12,
|
||||
borderRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_icons.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
|
||||
/// Shared sheet actions follow the selected design on every platform.
|
||||
class AppActionButton extends StatelessWidget {
|
||||
const AppActionButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.style,
|
||||
this.outlined = false,
|
||||
this.glass = true,
|
||||
this.tonal = false,
|
||||
this.isDestructive = false,
|
||||
this.fontSize = 17,
|
||||
});
|
||||
|
||||
final VoidCallback? onPressed;
|
||||
final Widget icon;
|
||||
final Widget label;
|
||||
final ButtonStyle? style;
|
||||
final bool outlined;
|
||||
final bool glass;
|
||||
final bool tonal;
|
||||
final bool isDestructive;
|
||||
final double fontSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!context.isMornye) {
|
||||
return outlined
|
||||
? OutlinedButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: icon,
|
||||
label: label,
|
||||
style: style,
|
||||
)
|
||||
: FilledButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: icon,
|
||||
label: label,
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final prominent = isDestructive || !outlined;
|
||||
final foreground = onPressed == null
|
||||
? scheme.onSurfaceVariant.withValues(alpha: 0.5)
|
||||
: prominent
|
||||
? scheme.onPrimary
|
||||
: scheme.primary;
|
||||
final action = CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
color: prominent
|
||||
? scheme.primary
|
||||
: tonal
|
||||
? MornyeTheme.controlFill(context)
|
||||
: Colors.transparent,
|
||||
disabledColor: !prominent && !tonal && !glass
|
||||
? Colors.transparent
|
||||
: tonal && !prominent
|
||||
? MornyeTheme.controlFill(context, enabled: false)
|
||||
: scheme.surfaceContainerHighest,
|
||||
onPressed: onPressed,
|
||||
child: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
|
||||
color: foreground,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
child: IconTheme(
|
||||
data: IconThemeData(color: foreground, size: 20),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon case Icon(:final icon?))
|
||||
Icon(mornyeIconFor(icon), size: 20)
|
||||
else
|
||||
icon,
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: label),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
// These buttons sit inside glass sheets/groups; avoid another blur pass.
|
||||
return prominent || tonal || !glass
|
||||
? action
|
||||
: MornyeGlass.navigation(radius: 28, blurEnabled: false, child: action);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
|
||||
/// Dialog presentation follows the selected design, including on Android.
|
||||
Future<T?> showAppDialog<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
bool barrierDismissible = true,
|
||||
bool useRootNavigator = true,
|
||||
}) {
|
||||
if (context.isMornye) {
|
||||
final navigator = Navigator.of(context, rootNavigator: useRootNavigator);
|
||||
final themes = InheritedTheme.capture(from: context, to: navigator.context);
|
||||
return navigator.push<T>(
|
||||
_MornyeDialogRoute<T>(
|
||||
context: context,
|
||||
builder: (context) => themes.wrap(Builder(builder: builder)),
|
||||
barrierDismissible: barrierDismissible,
|
||||
),
|
||||
);
|
||||
}
|
||||
return showDialog<T>(
|
||||
context: context,
|
||||
builder: builder,
|
||||
barrierDismissible: barrierDismissible,
|
||||
useRootNavigator: useRootNavigator,
|
||||
);
|
||||
}
|
||||
|
||||
/// The confirmation glass also hosts larger dialogs such as release notes.
|
||||
class AppDialogSurface extends StatelessWidget {
|
||||
const AppDialogSurface({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.maxWidth = 480,
|
||||
this.backgroundColor,
|
||||
this.shape,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final double maxWidth;
|
||||
final Color? backgroundColor;
|
||||
final ShapeBorder? shape;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mornye = context.isMornye;
|
||||
return Dialog(
|
||||
backgroundColor: mornye ? Colors.transparent : backgroundColor,
|
||||
surfaceTintColor: mornye ? Colors.transparent : null,
|
||||
elevation: mornye ? 0 : null,
|
||||
shape: shape,
|
||||
insetPadding: EdgeInsets.symmetric(
|
||||
horizontal: mornye ? 24 : 40,
|
||||
vertical: 24,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: mornye
|
||||
? MornyeGlassPanel.overlay(
|
||||
child: SingleChildScrollView(child: child),
|
||||
)
|
||||
: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MornyeDialogRoute<T> extends RawDialogRoute<T> {
|
||||
_MornyeDialogRoute({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
required super.barrierDismissible,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
builder(context),
|
||||
barrierColor: CupertinoDynamicColor.resolve(
|
||||
kCupertinoModalBarrierColor,
|
||||
context,
|
||||
),
|
||||
barrierLabel: CupertinoLocalizations.of(
|
||||
context,
|
||||
).modalBarrierDismissLabel,
|
||||
transitionDuration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 220),
|
||||
);
|
||||
|
||||
// Use a timed exit instead of CupertinoDialogRoute's settling spring.
|
||||
@override
|
||||
Duration get reverseTransitionDuration => transitionDuration == Duration.zero
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 160);
|
||||
|
||||
CurvedAnimation? _scaleAnimation;
|
||||
|
||||
@override
|
||||
Widget buildTransitions(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
if (MediaQuery.disableAnimationsOf(context)) return child;
|
||||
// Keep the backdrop in the page's compositing context. A fading dialog
|
||||
// creates an opacity layer that hides the page from its BackdropFilter.
|
||||
final scaleAnimation = _scaleAnimation ??= CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
return ScaleTransition(
|
||||
scale: scaleAnimation.drive(Tween<double>(begin: 0.96, end: 1)),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scaleAnimation?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class AppAlertDialog extends StatelessWidget {
|
||||
const AppAlertDialog({
|
||||
super.key,
|
||||
this.icon,
|
||||
this.title,
|
||||
this.content,
|
||||
this.actions = const [],
|
||||
this.backgroundColor,
|
||||
this.shape,
|
||||
});
|
||||
|
||||
final Widget? icon;
|
||||
final Widget? title;
|
||||
final Widget? content;
|
||||
final List<Widget> actions;
|
||||
final Color? backgroundColor;
|
||||
final ShapeBorder? shape;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!context.isMornye) {
|
||||
return AlertDialog(
|
||||
backgroundColor: backgroundColor,
|
||||
shape: shape,
|
||||
icon: icon,
|
||||
title: title,
|
||||
content: content,
|
||||
actions: actions,
|
||||
);
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final primaryActions = actions.where(
|
||||
(action) =>
|
||||
action is AppDialogAction && (action.isDestructive || action.filled),
|
||||
);
|
||||
final secondaryActions = actions.where(
|
||||
(action) =>
|
||||
action is! AppDialogAction ||
|
||||
(!action.isDestructive && !action.filled),
|
||||
);
|
||||
final orderedActions = [...primaryActions, ...secondaryActions];
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
child: MornyeGlassPanel.overlay(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (icon != null || title != null || content != null)
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: icon!,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (title != null)
|
||||
DefaultTextStyle(
|
||||
style: theme.textTheme.titleLarge!.copyWith(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
child: title!,
|
||||
),
|
||||
if (title != null && content != null)
|
||||
const SizedBox(height: 10),
|
||||
if (content != null)
|
||||
DefaultTextStyle(
|
||||
style: theme.textTheme.bodyLarge!.copyWith(
|
||||
fontSize: 17,
|
||||
height: 1.35,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
child: content!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
for (var i = 0; i < orderedActions.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 8),
|
||||
orderedActions[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppDialogAction extends StatelessWidget {
|
||||
const AppDialogAction({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required this.child,
|
||||
this.isDestructive = false,
|
||||
this.isDefault = false,
|
||||
this.filled = false,
|
||||
this.outlined = false,
|
||||
this.style,
|
||||
});
|
||||
|
||||
final VoidCallback? onPressed;
|
||||
final Widget child;
|
||||
final bool isDestructive;
|
||||
final bool isDefault;
|
||||
final bool filled;
|
||||
final bool outlined;
|
||||
final ButtonStyle? style;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (context.isMornye) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final color = onPressed == null
|
||||
? scheme.onSurfaceVariant
|
||||
: isDestructive
|
||||
? scheme.primary
|
||||
: scheme.onSurface;
|
||||
return CupertinoButton(
|
||||
onPressed: onPressed,
|
||||
color: MornyeTheme.controlFill(context),
|
||||
disabledColor: MornyeTheme.controlFill(context, enabled: false),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: DefaultTextStyle(
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge!.copyWith(
|
||||
color: color,
|
||||
fontSize: 17,
|
||||
fontWeight: isDestructive || isDefault || filled
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
),
|
||||
child: IconTheme(
|
||||
data: IconThemeData(color: color, size: 20),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return filled
|
||||
? FilledButton(onPressed: onPressed, style: style, child: child)
|
||||
: outlined
|
||||
? OutlinedButton(onPressed: onPressed, style: style, child: child)
|
||||
: TextButton(onPressed: onPressed, style: style, child: child);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/app_remote_config_service.dart';
|
||||
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
|
||||
|
||||
class AppAnnouncementDialog extends StatelessWidget {
|
||||
final RemoteAnnouncement announcement;
|
||||
@@ -68,7 +69,8 @@ class AppAnnouncementDialog extends StatelessWidget {
|
||||
|
||||
final actions = <Widget>[
|
||||
if (announcement.hasCta)
|
||||
FilledButton(
|
||||
AppDialogAction(
|
||||
filled: true,
|
||||
onPressed: () => _openCta(context),
|
||||
child: Text(announcement.ctaLabel!),
|
||||
),
|
||||
@@ -76,7 +78,7 @@ class AppAnnouncementDialog extends StatelessWidget {
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: AlertDialog(
|
||||
child: AppAlertDialog(
|
||||
title: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -111,7 +113,7 @@ class AppAnnouncementDialog extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: actions.isEmpty ? null : actions,
|
||||
actions: actions,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -122,7 +124,7 @@ Future<void> showAppAnnouncementDialog(
|
||||
required RemoteAnnouncement announcement,
|
||||
required VoidCallback onDismiss,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
return showAppDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) =>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart'
|
||||
show CupertinoButton, CupertinoDynamicColor, kCupertinoModalBarrierColor;
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_icons.dart';
|
||||
|
||||
/// The drag handle shown at the top of every modal sheet.
|
||||
///
|
||||
@@ -131,7 +136,7 @@ class AppBottomSheet extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
subtitle!,
|
||||
maxLines: 1,
|
||||
maxLines: context.isMornye ? 3 : 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
@@ -179,7 +184,7 @@ Future<T?> showAppBottomSheet<T>({
|
||||
bool showHandle = true,
|
||||
Color? backgroundColor,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
return showAppModalBottomSheet<T>(
|
||||
context: context,
|
||||
isScrollControlled: isScrollControlled,
|
||||
isDismissible: isDismissible,
|
||||
@@ -195,3 +200,128 @@ Future<T?> showAppBottomSheet<T>({
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Adaptive surface for sheets which already supply their own header/layout.
|
||||
Future<T?> showAppModalBottomSheet<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
bool isScrollControlled = false,
|
||||
bool isDismissible = true,
|
||||
bool enableDrag = true,
|
||||
bool useRootNavigator = false,
|
||||
bool useSafeArea = false,
|
||||
BoxConstraints? constraints,
|
||||
Color? backgroundColor,
|
||||
}) {
|
||||
final mornye = context.isMornye;
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
isScrollControlled: isScrollControlled,
|
||||
isDismissible: isDismissible,
|
||||
enableDrag: enableDrag,
|
||||
useRootNavigator: useRootNavigator,
|
||||
useSafeArea: useSafeArea,
|
||||
constraints: constraints,
|
||||
backgroundColor: mornye ? Colors.transparent : backgroundColor,
|
||||
barrierColor: mornye
|
||||
? CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context)
|
||||
: null,
|
||||
elevation: mornye ? 0 : null,
|
||||
builder: (context) => mornye
|
||||
? MornyeGlassPanel.overlay(child: Builder(builder: builder))
|
||||
: builder(context),
|
||||
);
|
||||
}
|
||||
|
||||
/// Flat rows sit directly on a sheet's glass; Material keeps its ListTile.
|
||||
class AppSheetOption extends StatelessWidget {
|
||||
const AppSheetOption({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.contentPadding,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final Widget title;
|
||||
final Widget? subtitle;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!context.isMornye) {
|
||||
return ListTile(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
leading: leading,
|
||||
trailing: trailing,
|
||||
onTap: onTap,
|
||||
contentPadding: contentPadding,
|
||||
enabled: enabled,
|
||||
);
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
Widget adaptedIcon(Widget widget, double size) {
|
||||
if (widget case Icon(:final icon?)) {
|
||||
return Icon(
|
||||
mornyeIconFor(icon),
|
||||
size: size,
|
||||
color: enabled ? widget.color ?? scheme.primary : theme.disabledColor,
|
||||
);
|
||||
}
|
||||
return widget;
|
||||
}
|
||||
|
||||
return CupertinoButton(
|
||||
onPressed: enabled ? onTap : null,
|
||||
padding:
|
||||
contentPadding ??
|
||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) ...[
|
||||
adaptedIcon(leading!, 24),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DefaultTextStyle(
|
||||
style: theme.textTheme.bodyLarge!.copyWith(
|
||||
color: enabled ? scheme.onSurface : theme.disabledColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
child: title,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
DefaultTextStyle(
|
||||
style: theme.textTheme.bodySmall!.copyWith(
|
||||
color: enabled
|
||||
? scheme.onSurfaceVariant
|
||||
: theme.disabledColor,
|
||||
),
|
||||
child: subtitle!,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
adaptedIcon(trailing!, 20),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
|
||||
/// A single tonal selection inside a sheet, without a second glass layer.
|
||||
class AppChoiceChip extends StatelessWidget {
|
||||
const AppChoiceChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onSelected,
|
||||
this.visualDensity,
|
||||
this.singleChoice = false,
|
||||
});
|
||||
|
||||
final Widget label;
|
||||
final bool selected;
|
||||
final ValueChanged<bool>? onSelected;
|
||||
final VisualDensity? visualDensity;
|
||||
final bool singleChoice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!context.isMornye) {
|
||||
if (singleChoice) {
|
||||
return ChoiceChip(
|
||||
label: label,
|
||||
selected: selected,
|
||||
onSelected: onSelected,
|
||||
visualDensity: visualDensity,
|
||||
);
|
||||
}
|
||||
return FilterChip(
|
||||
label: label,
|
||||
selected: selected,
|
||||
onSelected: onSelected,
|
||||
visualDensity: visualDensity,
|
||||
);
|
||||
}
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final foreground = onSelected == null
|
||||
? scheme.onSurfaceVariant
|
||||
: selected
|
||||
? scheme.primary
|
||||
: scheme.onSurface;
|
||||
return Semantics(
|
||||
selected: selected,
|
||||
child: CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: selected
|
||||
? scheme.primary.withValues(alpha: 0.12)
|
||||
: MornyeTheme.controlFill(context),
|
||||
disabledColor: MornyeTheme.controlFill(context, enabled: false),
|
||||
onPressed: onSelected == null ? null : () => onSelected!(!selected),
|
||||
child: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (selected) ...[
|
||||
Icon(CupertinoIcons.check_mark, size: 18, color: foreground),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(child: label),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
|
||||
/// One inline surface, matching the metadata groups and dialog controls.
|
||||
class AppContentCard extends StatelessWidget {
|
||||
const AppContentCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.color,
|
||||
this.shape,
|
||||
this.elevation,
|
||||
this.clipBehavior = Clip.none,
|
||||
this.preserveColor = false,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Color? color;
|
||||
final ShapeBorder? shape;
|
||||
final double? elevation;
|
||||
final Clip clipBehavior;
|
||||
final bool preserveColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!context.isMornye) {
|
||||
return Card(
|
||||
color: color,
|
||||
shape: shape,
|
||||
elevation: elevation,
|
||||
clipBehavior: clipBehavior,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return Material(
|
||||
color: preserveColor ? color : MornyeTheme.controlFill(context),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: DividerTheme(
|
||||
data: DividerTheme.of(
|
||||
context,
|
||||
).copyWith(color: MornyeTheme.metadataDividerColor(context)),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
/// The standard search field used by top-level app surfaces.
|
||||
@@ -12,8 +17,11 @@ class AppSearchField extends StatelessWidget {
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
required this.clearTooltip,
|
||||
required this.onChanged,
|
||||
this.onChanged,
|
||||
required this.onClear,
|
||||
this.onSubmitted,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.focusNode,
|
||||
this.autofocus = false,
|
||||
});
|
||||
@@ -21,17 +29,23 @@ class AppSearchField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String hintText;
|
||||
final String clearTooltip;
|
||||
final ValueChanged<String> onChanged;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final VoidCallback onClear;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final Widget? prefixIcon;
|
||||
final Widget? suffixIcon;
|
||||
final FocusNode? focusNode;
|
||||
final bool autofocus;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final radius = BorderRadius.circular(context.tokens.radiusSheet);
|
||||
final mornye = context.isMornye;
|
||||
final radius = BorderRadius.circular(
|
||||
mornye ? 28 : context.tokens.radiusSheet,
|
||||
);
|
||||
|
||||
return ValueListenableBuilder<TextEditingValue>(
|
||||
final field = ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: controller,
|
||||
builder: (context, value, _) => TextField(
|
||||
controller: controller,
|
||||
@@ -40,39 +54,64 @@ class AppSearchField extends StatelessWidget {
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: value.text.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: clearTooltip,
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
onClear();
|
||||
},
|
||||
),
|
||||
prefixIcon:
|
||||
prefixIcon ?? Icon(mornye ? CupertinoIcons.search : Icons.search),
|
||||
suffixIcon:
|
||||
suffixIcon ??
|
||||
(value.text.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: clearTooltip,
|
||||
icon: Icon(
|
||||
mornye
|
||||
? CupertinoIcons.clear_circled_solid
|
||||
: Icons.clear,
|
||||
),
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
onClear();
|
||||
},
|
||||
)),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||
borderSide: mornye
|
||||
? BorderSide.none
|
||||
: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||
borderSide: mornye
|
||||
? BorderSide.none
|
||||
: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||
borderSide: mornye
|
||||
? BorderSide.none
|
||||
: BorderSide(color: colorScheme.primary, width: 2),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: settingsGroupColor(context),
|
||||
fillColor: mornye ? Colors.transparent : settingsGroupColor(context),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
onTapOutside: (_) => FocusScope.of(context).unfocus(),
|
||||
),
|
||||
);
|
||||
if (!mornye) return field;
|
||||
return Consumer(
|
||||
builder: (context, ref, child) => MornyeGlass.navigation(
|
||||
radius: 28,
|
||||
blurEnabled:
|
||||
!ref.watch(lowEndDeviceProvider) ||
|
||||
ref.watch(backdropBlurEnabledProvider),
|
||||
child: child!,
|
||||
),
|
||||
child: field,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
|
||||
@@ -64,7 +65,9 @@ class AppSliverHeader extends StatelessWidget {
|
||||
tooltip: MaterialLocalizations.of(
|
||||
context,
|
||||
).backButtonTooltip,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
icon: Icon(
|
||||
context.isMornye ? Icons.chevron_left : Icons.arrow_back,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
)
|
||||
@@ -116,6 +119,7 @@ class AppSliverHeader extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: context.isMornye ? -0.7 * expandRatio : null,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
|
||||
/// Transient messages use the same glass as confirmation dialogs in Mornye.
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showAppSnackBar(
|
||||
BuildContext context, {
|
||||
required Widget content,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
SnackBarBehavior? behavior,
|
||||
}) {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
if (!context.isMornye) {
|
||||
return messenger.showSnackBar(
|
||||
SnackBar(content: content, duration: duration, behavior: behavior),
|
||||
);
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final snackBar = _MornyeSnackBar(
|
||||
duration: duration,
|
||||
reduceMotion: reduceMotion,
|
||||
content: Theme(
|
||||
data: theme,
|
||||
child: MornyeGlassPanel.overlay(
|
||||
radius: 24,
|
||||
// Messages have no modal scrim; let more of the page show through.
|
||||
tintOpacity: 0.60,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: DefaultTextStyle(
|
||||
style: theme.textTheme.bodyMedium!.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return messenger.showSnackBar(snackBar);
|
||||
}
|
||||
|
||||
class _MornyeSnackBar extends SnackBar {
|
||||
const _MornyeSnackBar({
|
||||
required super.content,
|
||||
required super.duration,
|
||||
required this.reduceMotion,
|
||||
}) : super(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
clipBehavior: Clip.none,
|
||||
padding: EdgeInsets.zero,
|
||||
margin: const EdgeInsets.fromLTRB(20, 8, 20, 12),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(24)),
|
||||
),
|
||||
);
|
||||
|
||||
final bool reduceMotion;
|
||||
|
||||
@override
|
||||
SnackBar withAnimation(Animation<double> newAnimation, {Key? fallbackKey}) {
|
||||
// Keep the messenger's controller/timer for queuing and dismissal, but
|
||||
// replace Material's opacity layer with movement so blur samples the page.
|
||||
return SnackBar(
|
||||
key: key ?? fallbackKey,
|
||||
duration: duration,
|
||||
behavior: behavior,
|
||||
backgroundColor: backgroundColor,
|
||||
elevation: elevation,
|
||||
clipBehavior: clipBehavior,
|
||||
padding: padding,
|
||||
margin: margin,
|
||||
shape: shape,
|
||||
animation: const AlwaysStoppedAnimation(1),
|
||||
content: AnimatedBuilder(
|
||||
animation: newAnimation,
|
||||
child: content,
|
||||
builder: (context, child) => Transform.translate(
|
||||
offset: Offset(
|
||||
0,
|
||||
reduceMotion
|
||||
? 0
|
||||
: 8 * (1 - Curves.easeOutCubic.transform(newAnimation.value)),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:liquid_glass_easy/liquid_glass_easy.dart';
|
||||
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
|
||||
/// One switch style for settings, extension controls and editing sheets.
|
||||
/// Mornye keeps its wide oval thumb even when glass or motion is disabled.
|
||||
class AppSwitch extends StatelessWidget {
|
||||
const AppSwitch({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.adaptive = false,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
final bool value;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
final bool adaptive;
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!context.isMornye) {
|
||||
return adaptive
|
||||
? Switch.adaptive(value: value, onChanged: onChanged)
|
||||
: Switch(value: value, onChanged: onChanged);
|
||||
}
|
||||
|
||||
return Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final enabled = onChanged != null;
|
||||
final useGlass =
|
||||
enabled &&
|
||||
!MediaQuery.disableAnimationsOf(context) &&
|
||||
!MediaQuery.highContrastOf(context) &&
|
||||
(!ref.watch(lowEndDeviceProvider) ||
|
||||
ref.watch(backdropBlurEnabledProvider));
|
||||
final inactive = scheme.brightness == Brightness.dark
|
||||
? const Color(0xff39393d)
|
||||
: const Color(0xff8e8e93);
|
||||
|
||||
final Widget control;
|
||||
if (useGlass) {
|
||||
control = Transform.flip(
|
||||
flipX: Directionality.of(context) == TextDirection.rtl,
|
||||
child: LiquidGlassSwitch(
|
||||
value: value,
|
||||
onChanged: onChanged!,
|
||||
activeColor: scheme.primary,
|
||||
inactiveColor: inactive,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
control = Container(
|
||||
width: 63,
|
||||
height: 28,
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: value ? scheme.primary : inactive,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Align(
|
||||
alignment: value
|
||||
? AlignmentDirectional.centerEnd
|
||||
: AlignmentDirectional.centerStart,
|
||||
child: Container(
|
||||
width: 37,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
label: semanticLabel,
|
||||
toggled: value,
|
||||
enabled: enabled,
|
||||
child: Opacity(
|
||||
opacity: enabled ? 1 : 0.5,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: enabled ? () => onChanged!(!value) : null,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
// InkWell supplies keyboard activation and an accessible tap
|
||||
// action; the glass control owns the inner drag gesture.
|
||||
child: ExcludeSemantics(
|
||||
child: SizedBox(
|
||||
width: 71,
|
||||
height: 44,
|
||||
child: Center(child: control),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppSwitchListTile extends StatelessWidget {
|
||||
const AppSwitchListTile({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.contentPadding,
|
||||
this.adaptive = false,
|
||||
});
|
||||
|
||||
final bool value;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
final Widget title;
|
||||
final Widget? subtitle;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
final bool adaptive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!context.isMornye) {
|
||||
return adaptive
|
||||
? SwitchListTile.adaptive(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
contentPadding: contentPadding,
|
||||
)
|
||||
: SwitchListTile(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
contentPadding: contentPadding,
|
||||
);
|
||||
}
|
||||
return MergeSemantics(
|
||||
child: ListTile(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
contentPadding: contentPadding,
|
||||
enabled: onChanged != null,
|
||||
onTap: onChanged == null ? null : () => onChanged!(!value),
|
||||
trailing: AppSwitch(value: value, onChanged: onChanged),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class _AudioInfoCard extends StatelessWidget {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final nyquist = data.sampleRate / 2;
|
||||
|
||||
return Card(
|
||||
return AppContentCard(
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
|
||||
@@ -28,7 +28,8 @@ class _SpectrogramView extends StatelessWidget {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
const labelColor = Color(0xFFB5B5B5);
|
||||
|
||||
return Card(
|
||||
final card = AppContentCard(
|
||||
preserveColor: true,
|
||||
color: Colors.black,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
@@ -41,7 +42,8 @@ class _SpectrogramView extends StatelessWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
ChoiceChip(
|
||||
AppChoiceChip(
|
||||
singleChoice: true,
|
||||
label: Text(context.l10n.audioAnalysisChannels),
|
||||
selected: selectedChannel < 0,
|
||||
onSelected: channelLoading
|
||||
@@ -51,7 +53,8 @@ class _SpectrogramView extends StatelessWidget {
|
||||
),
|
||||
for (var channel = 0; channel < channels; channel++) ...[
|
||||
const SizedBox(width: 6),
|
||||
ChoiceChip(
|
||||
AppChoiceChip(
|
||||
singleChoice: true,
|
||||
label: Text('Ch ${channel + 1}'),
|
||||
selected: selectedChannel == channel,
|
||||
onSelected: channelLoading
|
||||
@@ -141,6 +144,11 @@ class _SpectrogramView extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
// The plot stays black in both appearances; its controls need matching
|
||||
// contrast even when the surrounding Mornye page uses the light theme.
|
||||
return context.isMornye
|
||||
? Theme(data: MornyeTheme.build(Brightness.dark), child: card)
|
||||
: card;
|
||||
}
|
||||
|
||||
static List<Color> _legendColors() {
|
||||
|
||||
@@ -10,8 +10,11 @@ import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/services/audio_analysis_jobs.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/app_content_card.dart';
|
||||
import 'package:spotiflac_android/widgets/app_choice_chip.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
@@ -1540,7 +1543,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
if (_checkingCache) return const SizedBox.shrink();
|
||||
|
||||
if (_unsupportedCodec != null) {
|
||||
return Card(
|
||||
return AppContentCard(
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -1586,7 +1589,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
|
||||
if (_analyzing) {
|
||||
final isRescan = _data != null || _spectrogramImage != null;
|
||||
return Card(
|
||||
return AppContentCard(
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -1619,7 +1622,8 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Card(
|
||||
return AppContentCard(
|
||||
preserveColor: true,
|
||||
color: cs.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -1649,7 +1653,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
}
|
||||
|
||||
if (_data == null) {
|
||||
return Card(
|
||||
return AppContentCard(
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/app_action_button.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
import 'package:spotiflac_android/widgets/app_switch.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
/// Modern, card-based batch convert sheet shared by the queue and album
|
||||
/// screens, matching the single-track convert sheet styling.
|
||||
/// Conversion options shared by the queue, album and single-track sheets.
|
||||
class BatchConvertSheet extends StatefulWidget {
|
||||
final List<String> formats;
|
||||
final String title;
|
||||
@@ -93,7 +97,7 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, scrollController) => SafeArea(
|
||||
builder: (context, scrollController) => _sheetSurface(
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||
@@ -327,13 +331,21 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primaryContainer.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
decoration: context.isMornye
|
||||
? null
|
||||
: BoxDecoration(
|
||||
color: cs.primaryContainer.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.verified, size: 18, color: cs.primary),
|
||||
Icon(
|
||||
context.isMornye
|
||||
? CupertinoIcons.checkmark_seal
|
||||
: Icons.verified,
|
||||
size: 18,
|
||||
color: cs.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -364,7 +376,7 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
|
||||
_card(
|
||||
cs,
|
||||
child: SwitchListTile(
|
||||
child: AppSwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(context.l10n.trackConvertKeepOriginal),
|
||||
subtitle: Text(
|
||||
@@ -378,7 +390,7 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
child: AppActionButton(
|
||||
onPressed: () => widget.onConvert(
|
||||
_selectedFormat,
|
||||
_selectedBitrate,
|
||||
@@ -421,7 +433,27 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sheetSurface({required Widget child}) {
|
||||
final content = SafeArea(child: child);
|
||||
return context.isMornye
|
||||
? MornyeGlassPanel.overlay(child: content)
|
||||
: content;
|
||||
}
|
||||
|
||||
Widget _card(ColorScheme cs, {required Widget child}) {
|
||||
if (context.isMornye) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
child,
|
||||
const SizedBox(height: 20),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
@@ -458,6 +490,15 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
if (context.isMornye) {
|
||||
return MornyeFilterChip(
|
||||
label: label,
|
||||
selected: selected,
|
||||
onTap: onTap,
|
||||
icon: selected ? CupertinoIcons.checkmark : null,
|
||||
glass: false,
|
||||
);
|
||||
}
|
||||
return Material(
|
||||
color: selected ? cs.primaryContainer : cs.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
|
||||
|
||||
class _BatchProgress {
|
||||
final int current;
|
||||
@@ -65,7 +66,7 @@ class BatchProgressDialog extends StatefulWidget {
|
||||
_activeNotifier = ValueNotifier(const _BatchProgress());
|
||||
final notifier = _activeNotifier!;
|
||||
|
||||
showDialog<void>(
|
||||
showAppDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => BatchProgressDialog._(
|
||||
@@ -117,7 +118,7 @@ class _BatchProgressDialogState extends State<BatchProgressDialog> {
|
||||
final detail = widget._progressNotifier.value.detail;
|
||||
final progress = widget.total > 0 ? current / widget.total : 0.0;
|
||||
|
||||
return AlertDialog(
|
||||
return AppAlertDialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
content: Column(
|
||||
@@ -176,7 +177,7 @@ class _BatchProgressDialogState extends State<BatchProgressDialog> {
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
AppDialogAction(
|
||||
onPressed: widget.onCancel,
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
||||
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
|
||||
|
||||
/// Shared shell for every track-collection screen: album, playlist, local
|
||||
@@ -17,7 +19,7 @@ class CollectionScaffold extends StatefulWidget {
|
||||
required this.onExitSelectionMode,
|
||||
required this.appBar,
|
||||
required this.slivers,
|
||||
required this.bottomInset,
|
||||
this.bottomInset,
|
||||
this.selectionBar,
|
||||
this.backgroundColor,
|
||||
});
|
||||
@@ -32,8 +34,8 @@ class CollectionScaffold extends StatefulWidget {
|
||||
/// Content slivers below the header (track list, error card, footer, ...).
|
||||
final List<Widget> slivers;
|
||||
|
||||
/// Bottom inset needed to clear the shell navigation bar.
|
||||
final double bottomInset;
|
||||
/// Overrides the shell inset; otherwise only the trailing spacer watches it.
|
||||
final double? bottomInset;
|
||||
|
||||
/// Rebuilt by the caller each frame; shown only while [isSelectionMode] is
|
||||
/// true. Null means the screen has no multi-select.
|
||||
@@ -101,7 +103,10 @@ class _CollectionScaffoldState extends State<CollectionScaffold> {
|
||||
body: CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
slivers: [
|
||||
widget.appBar,
|
||||
if (widget.appBar case final AlbumDetailHeader header)
|
||||
...header.buildSlivers(context)
|
||||
else
|
||||
widget.appBar,
|
||||
...widget.slivers,
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
@@ -109,7 +114,7 @@ class _CollectionScaffoldState extends State<CollectionScaffold> {
|
||||
height: widget.isSelectionMode ? _selectionBarHeight : 32,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: widget.bottomInset)),
|
||||
NavBarSliverSpacer(bottomInset: widget.bottomInset),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -148,7 +148,7 @@ class _CrossExtensionShareTile extends StatelessWidget {
|
||||
final url = result.url;
|
||||
final hasUrl = result.found && url != null && url.isNotEmpty;
|
||||
|
||||
final tile = ListTile(
|
||||
final tile = AppSheetOption(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
width: 44,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/widgets/app_action_button.dart';
|
||||
|
||||
/// Full-width delete action for selection-mode bottom bars: destructive
|
||||
/// (error) styling once at least one item is selected, disabled/neutral
|
||||
@@ -20,7 +21,8 @@ class DestructiveSelectionButton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
child: AppActionButton(
|
||||
isDestructive: true,
|
||||
onPressed: count > 0 ? onPressed : null,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
/// Shared discard-unsaved-changes confirmation dialog used by priority /
|
||||
@@ -7,17 +8,20 @@ Future<bool> showDiscardChangesDialog(
|
||||
BuildContext context, {
|
||||
String? content,
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
final result = await showAppDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (context) => AppAlertDialog(
|
||||
title: Text(context.l10n.dialogDiscardChanges),
|
||||
content: Text(content ?? context.l10n.dialogUnsavedChanges),
|
||||
actions: [
|
||||
TextButton(
|
||||
AppDialogAction(
|
||||
isDefault: true,
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
AppDialogAction(
|
||||
filled: true,
|
||||
isDestructive: true,
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.dialogDiscard),
|
||||
),
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_icons.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
|
||||
class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
final String? trackName;
|
||||
@@ -39,19 +44,41 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
backgroundColor: context.isMornye
|
||||
? Colors.transparent
|
||||
: colorScheme.surfaceContainerHigh,
|
||||
elevation: context.isMornye ? 0 : null,
|
||||
barrierColor: context.isMornye
|
||||
? Colors.black.withValues(alpha: 0.26)
|
||||
: null,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
showDragHandle: !context.isMornye,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.88,
|
||||
),
|
||||
builder: (context) => DownloadServicePicker(
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
onSelect: onSelect,
|
||||
recommendedService: recommendedService,
|
||||
),
|
||||
builder: (context) {
|
||||
final picker = DownloadServicePicker(
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
onSelect: onSelect,
|
||||
recommendedService: recommendedService,
|
||||
);
|
||||
return context.isMornye
|
||||
? MornyeGlassPanel.overlay(
|
||||
tintOpacity: colorScheme.brightness == Brightness.dark
|
||||
? 0.68
|
||||
: 0.75,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppSheetHandle(),
|
||||
Flexible(child: picker),
|
||||
],
|
||||
),
|
||||
)
|
||||
: picker;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -120,6 +147,18 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
final downloadExtensions = _downloadExtensions();
|
||||
final hasProviders = downloadExtensions.isNotEmpty;
|
||||
final qualityOptions = _getQualityOptions(downloadExtensions);
|
||||
final qualityRows = [
|
||||
for (final quality in qualityOptions)
|
||||
_QualityOption(
|
||||
title: _localizedQualityLabel(context, quality),
|
||||
subtitle: _localizedQualityDescription(context, quality),
|
||||
icon: _getQualityIcon(quality.id),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onSelect(quality.id, _selectedService);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
@@ -160,9 +199,8 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
children: [
|
||||
for (final ext in downloadExtensions)
|
||||
_ServiceChip(
|
||||
label: widget.recommendedService == ext.id
|
||||
? '${ext.displayName} (Recommended)'
|
||||
: ext.displayName,
|
||||
label: ext.displayName,
|
||||
isRecommended: widget.recommendedService == ext.id,
|
||||
healthStatus: ext.hasServiceHealth
|
||||
? extensionState.healthStatuses[ext.id]?.status
|
||||
: null,
|
||||
@@ -179,7 +217,12 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
),
|
||||
if (hasProviders) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24,
|
||||
context.isMornye ? 24 : 16,
|
||||
24,
|
||||
context.isMornye ? 12 : 8,
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.downloadSelectQuality,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
@@ -187,16 +230,32 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final quality in qualityOptions)
|
||||
_QualityOption(
|
||||
title: _localizedQualityLabel(context, quality),
|
||||
subtitle: _localizedQualityDescription(context, quality),
|
||||
icon: _getQualityIcon(quality.id),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onSelect(quality.id, _selectedService);
|
||||
},
|
||||
),
|
||||
if (context.isMornye)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Material(
|
||||
color: MornyeTheme.controlFill(context),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < qualityRows.length; i++) ...[
|
||||
if (i > 0)
|
||||
Divider(
|
||||
height: 0.5,
|
||||
indent: 52,
|
||||
endIndent: 16,
|
||||
color: MornyeTheme.metadataDividerColor(context),
|
||||
),
|
||||
qualityRows[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...qualityRows,
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
@@ -273,6 +332,53 @@ class _QualityOption extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
if (context.isMornye) {
|
||||
return CupertinoButton(
|
||||
padding: const EdgeInsets.all(16),
|
||||
onPressed: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Icon(
|
||||
mornyeIconFor(icon),
|
||||
color: colorScheme.primary,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
CupertinoIcons.chevron_right,
|
||||
size: 14,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
@@ -298,6 +404,7 @@ class _QualityOption extends StatelessWidget {
|
||||
class _ServiceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final bool isRecommended;
|
||||
final VoidCallback? onTap;
|
||||
final String? iconPath;
|
||||
final String? healthStatus;
|
||||
@@ -306,6 +413,7 @@ class _ServiceChip extends StatelessWidget {
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.isRecommended = false,
|
||||
this.iconPath,
|
||||
this.healthStatus,
|
||||
});
|
||||
@@ -313,6 +421,76 @@ class _ServiceChip extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final accessibleLabel = isRecommended ? '$label (Recommended)' : label;
|
||||
if (context.isMornye) {
|
||||
final foreground = isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: isSelected,
|
||||
hint: isRecommended ? 'Recommended' : null,
|
||||
child: CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: isSelected
|
||||
? colorScheme.primary.withValues(alpha: 0.14)
|
||||
: MornyeTheme.controlFill(context),
|
||||
onPressed: onTap,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isSelected) ...[
|
||||
Icon(CupertinoIcons.checkmark, color: foreground, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (iconPath != null) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image.file(
|
||||
File(iconPath!),
|
||||
width: 18,
|
||||
height: 18,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Icon(
|
||||
CupertinoIcons.square_grid_2x2,
|
||||
size: 18,
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isRecommended) ...[
|
||||
const SizedBox(width: 6),
|
||||
Tooltip(
|
||||
message: accessibleLabel,
|
||||
excludeFromSemantics: true,
|
||||
child: Icon(
|
||||
CupertinoIcons.star_fill,
|
||||
size: 12,
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (healthStatus != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
_ServiceHealthDot(status: healthStatus!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
@@ -356,7 +534,7 @@ class _ServiceChip extends StatelessWidget {
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
accessibleLabel,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -92,18 +93,19 @@ class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
|
||||
}
|
||||
|
||||
Future<bool> _confirmDelete(String message) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await showAppDialog<bool>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
builder: (ctx) => AlertDialog(
|
||||
builder: (ctx) => AppAlertDialog(
|
||||
title: Text(ctx.l10n.dialogDeleteSelectedTitle),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
AppDialogAction(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(ctx.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
AppDialogAction(
|
||||
filled: true,
|
||||
isDestructive: true,
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
|
||||
/// Repo cards share the Mornye glass material, including their detail pages.
|
||||
class ExtensionRepoCard extends ConsumerWidget {
|
||||
const ExtensionRepoCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.color,
|
||||
this.radius = 20,
|
||||
this.margin = const EdgeInsets.all(4),
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Color? color;
|
||||
final double radius;
|
||||
final EdgeInsetsGeometry margin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (!context.isMornye) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: color,
|
||||
margin: margin,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: margin,
|
||||
child: MornyeGlass.navigation(
|
||||
radius: 24,
|
||||
blurEnabled:
|
||||
!ref.watch(lowEndDeviceProvider) ||
|
||||
ref.watch(backdropBlurEnabledProvider),
|
||||
child: Material(color: Colors.transparent, child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/screens/now_playing_screen.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
|
||||
class MiniPlayer extends ConsumerStatefulWidget {
|
||||
const MiniPlayer({super.key});
|
||||
const MiniPlayer({super.key, this.compact = false, this.bottomPadding = 8});
|
||||
|
||||
final bool compact;
|
||||
final double bottomPadding;
|
||||
|
||||
@override
|
||||
ConsumerState<MiniPlayer> createState() => _MiniPlayerState();
|
||||
@@ -34,8 +41,10 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
|
||||
final controller = ref.read(musicPlayerControllerProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final mornye = context.isMornye;
|
||||
final compact = mornye && widget.compact;
|
||||
|
||||
return Dismissible(
|
||||
final player = Dismissible(
|
||||
key: ValueKey('mini-player-${mediaItem.id}'),
|
||||
direction: DismissDirection.horizontal,
|
||||
onDismissed: (_) {
|
||||
@@ -45,14 +54,18 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
child: DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
border: mornye
|
||||
? null
|
||||
: Border(
|
||||
top: BorderSide(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: settingsGroupColor(context).withValues(alpha: 0.72),
|
||||
color: mornye
|
||||
? Colors.transparent
|
||||
: settingsGroupColor(context).withValues(alpha: 0.72),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(
|
||||
@@ -63,14 +76,15 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_MiniPlayerProgress(
|
||||
duration: mediaItem.duration ?? Duration.zero,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
if (!mornye)
|
||||
_MiniPlayerProgress(
|
||||
duration: mediaItem.duration ?? Duration.zero,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 8 : 12,
|
||||
vertical: mornye ? 0 : 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -79,8 +93,8 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
width: mornye ? 38 : 44,
|
||||
height: mornye ? 38 : 44,
|
||||
child: PlayerArtwork(
|
||||
artUri: mediaItem.artUri?.toString(),
|
||||
colorScheme: colorScheme,
|
||||
@@ -90,7 +104,7 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(width: compact ? 8 : 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -112,7 +126,16 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
mediaItem.artist ?? '',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
color: mornye
|
||||
? Color.lerp(
|
||||
colorScheme.onSurfaceVariant,
|
||||
colorScheme.onSurface,
|
||||
colorScheme.brightness ==
|
||||
Brightness.dark
|
||||
? 0.7
|
||||
: 0.6,
|
||||
)
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -121,6 +144,7 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
color: mornye ? colorScheme.onSurface : null,
|
||||
tooltip: isPlaying
|
||||
? context.l10n.actionPause
|
||||
: context.l10n.tooltipPlay,
|
||||
@@ -131,16 +155,30 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
)
|
||||
: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
|
||||
: Icon(
|
||||
mornye
|
||||
? (isPlaying
|
||||
? CupertinoIcons.pause_fill
|
||||
: CupertinoIcons.play_fill)
|
||||
: (isPlaying
|
||||
? Icons.pause
|
||||
: Icons.play_arrow),
|
||||
),
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => controller.togglePlayPause(isPlaying),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: context.l10n.nowPlayingNextTrack,
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: controller.next,
|
||||
),
|
||||
if (!compact)
|
||||
IconButton(
|
||||
color: mornye ? colorScheme.onSurface : null,
|
||||
tooltip: context.l10n.nowPlayingNextTrack,
|
||||
icon: Icon(
|
||||
mornye
|
||||
? CupertinoIcons.forward_fill
|
||||
: Icons.skip_next,
|
||||
),
|
||||
onPressed: controller.next,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -150,6 +188,19 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
),
|
||||
),
|
||||
);
|
||||
return mornye
|
||||
? Padding(
|
||||
padding: EdgeInsets.only(bottom: widget.bottomPadding),
|
||||
child: MornyeGlass.navigation(
|
||||
strongTint: true,
|
||||
tintOpacity: MornyeTheme.chromeOpacity(context),
|
||||
blurEnabled:
|
||||
!ref.watch(lowEndDeviceProvider) ||
|
||||
ref.watch(backdropBlurEnabledProvider),
|
||||
child: player,
|
||||
),
|
||||
)
|
||||
: player;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/cover_palette.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
||||
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
||||
|
||||
/// Share the same artwork-derived dark surface while loading and after the
|
||||
/// artist metadata arrives, including when the app itself uses light mode.
|
||||
class MornyeArtistSurface extends StatefulWidget {
|
||||
const MornyeArtistSurface({
|
||||
super.key,
|
||||
required this.imageSource,
|
||||
required this.child,
|
||||
this.neutralActions = false,
|
||||
});
|
||||
|
||||
final String? imageSource;
|
||||
final Widget child;
|
||||
|
||||
/// Album and playlist actions use white controls over the artwork tint.
|
||||
final bool neutralActions;
|
||||
|
||||
@override
|
||||
State<MornyeArtistSurface> createState() => _MornyeArtistSurfaceState();
|
||||
}
|
||||
|
||||
class _MornyeArtistSurfaceState extends State<MornyeArtistSurface> {
|
||||
@override
|
||||
void dispose() {
|
||||
ShellNavigationService.clearChromeBrightness(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final darkTheme = MornyeTheme.build(Brightness.dark);
|
||||
return Theme(
|
||||
data: darkTheme.copyWith(
|
||||
colorScheme: darkTheme.colorScheme.copyWith(primary: Colors.grey),
|
||||
),
|
||||
child: CoverPaletteBuilder(
|
||||
imageSource: widget.imageSource,
|
||||
builder: (context, palette) {
|
||||
// Retain the artwork's hue with a muted, slightly lifted surface.
|
||||
// Large backgrounds need less saturation than the cover's accent.
|
||||
final dominant = HSLColor.fromColor(palette.primary);
|
||||
final surface = dominant
|
||||
.withSaturation((dominant.saturation * 0.45).clamp(0.0, 0.22))
|
||||
.withLightness(0.34)
|
||||
.toColor();
|
||||
final route = ModalRoute.of(context);
|
||||
if (route != null) {
|
||||
ShellNavigationService.setChromeBrightness(
|
||||
owner: this,
|
||||
route: route,
|
||||
brightness: Brightness.dark,
|
||||
surface: surface,
|
||||
);
|
||||
}
|
||||
return Theme(
|
||||
data: darkTheme.copyWith(
|
||||
scaffoldBackgroundColor: surface,
|
||||
colorScheme: darkTheme.colorScheme.copyWith(
|
||||
primary: widget.neutralActions ? Colors.white : null,
|
||||
onPrimary: widget.neutralActions ? surface : null,
|
||||
onSurfaceVariant: widget.neutralActions ? Colors.white70 : null,
|
||||
surface: surface,
|
||||
surfaceContainer: Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
surface,
|
||||
),
|
||||
surfaceContainerHigh: Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.12),
|
||||
surface,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Portrait and centered identity from Mornye's MobileArtistView. Actions stay
|
||||
/// supplied by the screen so remote artists retain download/favorite behavior.
|
||||
class MornyeArtistHeader extends StatelessWidget {
|
||||
const MornyeArtistHeader({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.artwork,
|
||||
required this.actions,
|
||||
required this.showTitle,
|
||||
this.listeners,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final Widget artwork;
|
||||
final List<Widget> actions;
|
||||
final bool showTitle;
|
||||
final String? listeners;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
SliverMainAxisGroup(slivers: buildSlivers(context));
|
||||
|
||||
List<Widget> buildSlivers(BuildContext context) {
|
||||
final surface = Theme.of(context).colorScheme.surface;
|
||||
return [
|
||||
// Padding changes while the shell folds. Keep that dependency on the
|
||||
// toolbar so callers do not rebuild the artist's entire discography.
|
||||
Builder(
|
||||
builder: (context) => SliverAppBar(
|
||||
pinned: true,
|
||||
expandedHeight: 342 - MediaQuery.paddingOf(context).top,
|
||||
backgroundColor: surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leadingWidth: 64,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: HeaderCircleButton(
|
||||
icon: Icons.arrow_back,
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
title: AnimatedOpacity(
|
||||
opacity: showTitle ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.pin,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
artwork,
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0, 0.52, 1],
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.15),
|
||||
surface.withValues(alpha: 0),
|
||||
surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 4),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
if (listeners != null) ...[
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
listeners!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (actions.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 24,
|
||||
children: actions,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mini_player.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
|
||||
/// Only deliberate vertical drags change the chrome. Horizontal artwork rows,
|
||||
/// programmatic scrolling and edge bounce must not minimize the navigation.
|
||||
class MornyeChromeController extends ValueNotifier<bool> {
|
||||
MornyeChromeController() : super(false);
|
||||
|
||||
double _distance = 0;
|
||||
|
||||
void expand() {
|
||||
_distance = 0;
|
||||
value = false;
|
||||
}
|
||||
|
||||
bool handleScroll(ScrollNotification notification) {
|
||||
if (notification.depth != 0 || notification.metrics.axis != Axis.vertical) {
|
||||
return false;
|
||||
}
|
||||
if (notification is ScrollStartNotification ||
|
||||
notification is ScrollEndNotification) {
|
||||
_distance = 0;
|
||||
}
|
||||
if (notification is! ScrollUpdateNotification ||
|
||||
notification.dragDetails == null) {
|
||||
return false;
|
||||
}
|
||||
final metrics = notification.metrics;
|
||||
if (metrics.pixels <= metrics.minScrollExtent + 12) {
|
||||
expand();
|
||||
return false;
|
||||
}
|
||||
if (metrics.outOfRange) return false;
|
||||
final delta = notification.scrollDelta ?? 0;
|
||||
if (delta == 0) return false;
|
||||
if (_distance.sign != delta.sign) _distance = 0;
|
||||
_distance += delta;
|
||||
if (_distance >= 28) {
|
||||
value = true;
|
||||
_distance = 0;
|
||||
} else if (_distance <= -18) {
|
||||
expand();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// A single mini-player survives the transition, preserving its artwork Hero,
|
||||
/// playback controls and swipe-to-dismiss state as the tabs fold away.
|
||||
class MornyeBottomBar extends ConsumerWidget {
|
||||
const MornyeBottomBar({
|
||||
super.key,
|
||||
required this.collapsed,
|
||||
required this.destinations,
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
required this.onExpand,
|
||||
required this.onSearch,
|
||||
required this.blurEnabled,
|
||||
});
|
||||
|
||||
final bool collapsed;
|
||||
final List<NavigationDestination> destinations;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
final VoidCallback onExpand;
|
||||
final VoidCallback onSearch;
|
||||
final bool blurEnabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasPlayer = ref.watch(
|
||||
currentMediaItemProvider.select((item) => item.value != null),
|
||||
);
|
||||
// Animated glass tabs already reserve 8px above their visible capsule.
|
||||
final tabGap =
|
||||
blurEnabled &&
|
||||
!MediaQuery.disableAnimationsOf(context) &&
|
||||
!MediaQuery.highContrastOf(context)
|
||||
? 0.0
|
||||
: 8.0;
|
||||
// These contents do not depend on animation progress. Retain their widget
|
||||
// instances so folding only updates size/opacity wrappers each frame.
|
||||
Widget sideContent({
|
||||
required Widget icon,
|
||||
required String tooltip,
|
||||
required VoidCallback onPressed,
|
||||
}) => RepaintBoundary(
|
||||
child: MornyeGlass.navigation(
|
||||
blurEnabled: blurEnabled,
|
||||
strongTint: true,
|
||||
tintOpacity: MornyeTheme.chromeOpacity(context),
|
||||
radius: 26,
|
||||
child: SizedBox.square(
|
||||
dimension: 52,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
icon: icon,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final expandButton = sideContent(
|
||||
icon: destinations[selectedIndex].icon,
|
||||
tooltip: context.l10n.mornyeShowTabs,
|
||||
onPressed: onExpand,
|
||||
);
|
||||
final searchButton = sideContent(
|
||||
icon: const Icon(CupertinoIcons.search),
|
||||
tooltip: context.l10n.mornyeSearch,
|
||||
onPressed: onSearch,
|
||||
);
|
||||
final player = MiniPlayer(compact: collapsed, bottomPadding: 0);
|
||||
final tabs = TickerMode(
|
||||
enabled: !collapsed,
|
||||
child: RepaintBoundary(
|
||||
child: MornyeTabBar(
|
||||
destinations: destinations,
|
||||
selectedIndex: selectedIndex,
|
||||
onSelected: onSelected,
|
||||
blurEnabled: blurEnabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: collapsed ? 1 : 0),
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 380),
|
||||
curve: Curves.easeInOutCubic,
|
||||
builder: (context, amount, _) {
|
||||
Widget sideButton({
|
||||
required Widget child,
|
||||
required Alignment alignment,
|
||||
}) => ClipRect(
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
widthFactor: amount,
|
||||
child: SizedBox(
|
||||
width: 60,
|
||||
height: 48 + 4 * amount,
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
child: IgnorePointer(
|
||||
ignoring: !collapsed,
|
||||
child: ExcludeSemantics(
|
||||
excluding: !collapsed,
|
||||
child: Opacity(opacity: amount, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (hasPlayer || amount > 0)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: tabGap + (8 - tabGap) * amount,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
sideButton(
|
||||
child: expandButton,
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
Expanded(child: player),
|
||||
sideButton(
|
||||
child: searchButton,
|
||||
alignment: Alignment.centerRight,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
heightFactor: 1 - amount,
|
||||
child: IgnorePointer(
|
||||
ignoring: collapsed,
|
||||
child: ExcludeSemantics(
|
||||
excluding: collapsed,
|
||||
child: Opacity(opacity: 1 - amount, child: tabs),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:liquid_glass_easy/liquid_glass_easy.dart';
|
||||
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
|
||||
class MornyeSegmentedControl extends ConsumerWidget {
|
||||
const MornyeSegmentedControl({
|
||||
super.key,
|
||||
required this.labels,
|
||||
required this.selectedIndex,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final List<String> labels;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final blur =
|
||||
!MediaQuery.highContrastOf(context) &&
|
||||
(!ref.watch(lowEndDeviceProvider) ||
|
||||
ref.watch(backdropBlurEnabledProvider));
|
||||
final animate = !MediaQuery.disableAnimationsOf(context);
|
||||
final height = MediaQuery.textScalerOf(context).scale(15) + 36;
|
||||
final rtl = Directionality.of(context) == TextDirection.rtl;
|
||||
final visualLabels = rtl ? labels.reversed.toList() : labels;
|
||||
int logicalIndex(int index) => rtl ? labels.length - 1 - index : index;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => SizedBox(
|
||||
height: height + 16,
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeBottom: true,
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 8,
|
||||
height: height,
|
||||
child: _MornyeGlassSurface(
|
||||
blurEnabled: blur,
|
||||
radius: height / 2,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
LiquidGlassTabBar.withImpeller(
|
||||
width: constraints.maxWidth,
|
||||
height: height,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
itemPadding: 4,
|
||||
selectedIndex: logicalIndex(selectedIndex),
|
||||
onChanged: (index) => onChanged(logicalIndex(index)),
|
||||
style: const LiquidGlassStyle(
|
||||
shape: LiquidGlassShape.continuousRoundedRectangle(
|
||||
cornerRadius: 32,
|
||||
borderWidth: 0,
|
||||
lightIntensity: 0,
|
||||
),
|
||||
appearance: LiquidGlassAppearance(),
|
||||
refraction: LiquidGlassRefraction(
|
||||
distortion: 0,
|
||||
chromaticAberration: 0,
|
||||
),
|
||||
),
|
||||
pillStyle: LiquidGlassTabPillStyle(
|
||||
mode: blur && animate
|
||||
? LiquidGlassPillMode.impellerOnly
|
||||
: LiquidGlassPillMode.none,
|
||||
animated: animate,
|
||||
),
|
||||
itemStyle: LiquidGlassTabItemStyle(
|
||||
selectedColor: scheme.onSurface,
|
||||
unselectedColor: scheme.onSurface,
|
||||
iconSize: 0,
|
||||
iconLabelGap: 0,
|
||||
labelFontSize: 15,
|
||||
),
|
||||
items: [
|
||||
for (final label in visualLabels)
|
||||
LiquidGlassTabBarItem(
|
||||
label: label,
|
||||
iconBuilder: (_, _) => const SizedBox.shrink(),
|
||||
labelBuilder: (context, state) => Text(
|
||||
label,
|
||||
textDirection: rtl
|
||||
? TextDirection.rtl
|
||||
: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurface,
|
||||
fontWeight: state.selected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounded, translucent chrome used by Mornye's tab accessory. Blur is
|
||||
/// limited to the control itself and follows the app's device performance gate.
|
||||
class MornyeGlassPanel extends ConsumerWidget {
|
||||
const MornyeGlassPanel({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.radius = 32,
|
||||
this.strongTint = false,
|
||||
this.tintOpacity,
|
||||
});
|
||||
|
||||
/// Shared glass material for confirmation dialogs and floating sheets.
|
||||
const MornyeGlassPanel.overlay({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.radius = 32,
|
||||
this.tintOpacity = 0.78,
|
||||
}) : strongTint = false;
|
||||
|
||||
final Widget child;
|
||||
final double radius;
|
||||
final bool strongTint;
|
||||
final double? tintOpacity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => MornyeGlass.navigation(
|
||||
radius: radius,
|
||||
strongTint: strongTint,
|
||||
tintOpacity: tintOpacity,
|
||||
blurEnabled:
|
||||
!MediaQuery.highContrastOf(context) &&
|
||||
(!ref.watch(lowEndDeviceProvider) ||
|
||||
ref.watch(backdropBlurEnabledProvider)),
|
||||
child: Material(
|
||||
color: strongTint
|
||||
? Theme.of(context).colorScheme.surface.withValues(alpha: 0.60)
|
||||
: Colors.transparent,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MornyeGlass extends StatelessWidget {
|
||||
const MornyeGlass({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.blurEnabled,
|
||||
this.radius = 32,
|
||||
this.strongTint = false,
|
||||
this.tintOpacity,
|
||||
this.tintColor,
|
||||
}) : _useLens = true,
|
||||
firstInGroup = true,
|
||||
lastInGroup = true;
|
||||
|
||||
/// Shares the navigation bar's tint, blur and visible outer rim.
|
||||
const MornyeGlass.navigation({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.blurEnabled,
|
||||
this.radius = 32,
|
||||
this.firstInGroup = true,
|
||||
this.lastInGroup = true,
|
||||
this.strongTint = false,
|
||||
this.tintOpacity,
|
||||
this.tintColor,
|
||||
}) : _useLens = false;
|
||||
|
||||
final Widget child;
|
||||
final bool blurEnabled;
|
||||
final double radius;
|
||||
final bool _useLens;
|
||||
final bool firstInGroup;
|
||||
final bool lastInGroup;
|
||||
|
||||
/// Keeps floating controls readable over arbitrary album artwork.
|
||||
final bool strongTint;
|
||||
|
||||
/// Uses a single tint instead of layered highlights for translucent surfaces.
|
||||
final double? tintOpacity;
|
||||
final Color? tintColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final dark = scheme.brightness == Brightness.dark;
|
||||
final useGlass =
|
||||
_useLens && blurEnabled && !MediaQuery.highContrastOf(context);
|
||||
return _MornyeGlassSurface(
|
||||
blurEnabled: blurEnabled,
|
||||
radius: radius,
|
||||
firstInGroup: firstInGroup,
|
||||
lastInGroup: lastInGroup,
|
||||
strongTint: strongTint,
|
||||
tintOpacity: tintOpacity,
|
||||
tintColor: tintColor,
|
||||
child: useGlass
|
||||
? LiquidGlassLens(
|
||||
style: LiquidGlassStyle(
|
||||
shape: LiquidGlassShape.continuousRoundedRectangle(
|
||||
cornerRadius: radius,
|
||||
borderWidth: dark && tintOpacity == null ? 0 : 0.6,
|
||||
lightIntensity: dark && tintOpacity == null ? 0 : 0.18,
|
||||
),
|
||||
appearance: LiquidGlassAppearance(
|
||||
color: tintOpacity != null
|
||||
? Colors.transparent
|
||||
: dark
|
||||
? scheme.surfaceContainerHigh.withValues(alpha: 0.28)
|
||||
: Colors.white.withValues(
|
||||
alpha: strongTint ? 0.20 : 0.55,
|
||||
),
|
||||
// The surface already blurs the backdrop. Refracting that
|
||||
// frosted result needs no second Gaussian blur pass.
|
||||
blur: const LiquidGlassBlur(),
|
||||
),
|
||||
refraction: const LiquidGlassRefraction(
|
||||
distortion: 0.02,
|
||||
distortionWidth: 8,
|
||||
chromaticAberration: 0,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
)
|
||||
: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the capsule legible independently of the shader renderer. The liquid
|
||||
/// lens adds refraction above this frosted base, never above bare page text.
|
||||
class _MornyeGlassSurface extends StatelessWidget {
|
||||
static final _backdropBlur = ImageFilter.blur(sigmaX: 18, sigmaY: 18);
|
||||
|
||||
const _MornyeGlassSurface({
|
||||
required this.child,
|
||||
required this.blurEnabled,
|
||||
this.radius = 32,
|
||||
this.firstInGroup = true,
|
||||
this.lastInGroup = true,
|
||||
this.strongTint = false,
|
||||
this.tintOpacity,
|
||||
this.tintColor,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool blurEnabled;
|
||||
final double radius;
|
||||
final bool firstInGroup;
|
||||
final bool lastInGroup;
|
||||
final bool strongTint;
|
||||
final double? tintOpacity;
|
||||
final Color? tintColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final useBlur = blurEnabled && !MediaQuery.highContrastOf(context);
|
||||
final dark = scheme.brightness == Brightness.dark;
|
||||
final shape = BorderRadius.vertical(
|
||||
top: firstInGroup ? Radius.circular(radius) : Radius.zero,
|
||||
bottom: lastInGroup ? Radius.circular(radius) : Radius.zero,
|
||||
);
|
||||
final rim = BorderSide(
|
||||
color: dark
|
||||
? Colors.white.withValues(alpha: 0.16)
|
||||
: Colors.black.withValues(alpha: 0.17),
|
||||
width: 0.75,
|
||||
);
|
||||
final border = Border(
|
||||
top: firstInGroup ? rim : BorderSide.none,
|
||||
bottom: lastInGroup ? rim : BorderSide.none,
|
||||
left: rim,
|
||||
right: rim,
|
||||
);
|
||||
final surface = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: (tintColor ?? scheme.surfaceContainerHigh).withValues(
|
||||
alpha: useBlur ? (tintOpacity ?? (strongTint ? 0.80 : 0.60)) : 1,
|
||||
),
|
||||
gradient: useBlur && !dark && tintOpacity == null
|
||||
? LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0, 0.35, 0.75, 1],
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.90),
|
||||
Colors.white.withValues(alpha: strongTint ? 0.80 : 0.78),
|
||||
scheme.surfaceContainerHigh.withValues(
|
||||
alpha: strongTint ? 0.76 : 0.72,
|
||||
),
|
||||
Colors.white.withValues(alpha: 0.85),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
borderRadius: shape,
|
||||
border: dark ? border : null,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: shape,
|
||||
boxShadow: firstInGroup && lastInGroup
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: dark ? 0.2 : 0.06),
|
||||
blurRadius: dark ? 18 : 10,
|
||||
offset: Offset(0, dark ? 4 : 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
// Paint the light outline above the lens so its pale tint cannot wash
|
||||
// the edge out on an all-white page. Dark chrome keeps its quiet rim.
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: shape,
|
||||
border: dark ? null : border,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: shape,
|
||||
child: useBlur
|
||||
? BackdropFilter(filter: _backdropBlur, child: surface)
|
||||
: surface,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A searchable category uses the same material as the navigation capsule.
|
||||
class MornyeFilterChip extends ConsumerWidget {
|
||||
const MornyeFilterChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.icon,
|
||||
this.blurEnabled = true,
|
||||
this.glass = true,
|
||||
this.tonal = true,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback? onTap;
|
||||
final IconData? icon;
|
||||
final bool blurEnabled;
|
||||
final bool glass;
|
||||
final bool tonal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final content = Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
enabled: onTap != null,
|
||||
child: Material(
|
||||
color: !glass && !tonal
|
||||
? Colors.transparent
|
||||
: selected
|
||||
? scheme.primary.withValues(alpha: 0.12)
|
||||
: glass
|
||||
? Colors.transparent
|
||||
: MornyeTheme.controlFill(context, enabled: onTap != null),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 18, color: scheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: selected ? scheme.primary : scheme.onSurface,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!glass) {
|
||||
return ClipRRect(borderRadius: BorderRadius.circular(24), child: content);
|
||||
}
|
||||
return MornyeGlass.navigation(
|
||||
radius: 24,
|
||||
blurEnabled:
|
||||
blurEnabled &&
|
||||
(!ref.watch(lowEndDeviceProvider) ||
|
||||
ref.watch(backdropBlurEnabledProvider)),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MornyeTabBar extends StatelessWidget {
|
||||
const MornyeTabBar({
|
||||
super.key,
|
||||
required this.destinations,
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
required this.blurEnabled,
|
||||
});
|
||||
|
||||
final List<NavigationDestination> destinations;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
final bool blurEnabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final inactiveIconColor = Color.lerp(
|
||||
scheme.onSurfaceVariant,
|
||||
scheme.onSurface,
|
||||
scheme.brightness == Brightness.dark ? 0.5 : 0.4,
|
||||
);
|
||||
if (blurEnabled &&
|
||||
!MediaQuery.disableAnimationsOf(context) &&
|
||||
!MediaQuery.highContrastOf(context)) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => SizedBox(
|
||||
// Leave room above and below for the travelling pill to lift and
|
||||
// stretch; its shader must not be clipped to the resting capsule.
|
||||
height: 80,
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeBottom: true,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 8,
|
||||
height: 64,
|
||||
child: _MornyeGlassSurface(
|
||||
blurEnabled: blurEnabled,
|
||||
strongTint: true,
|
||||
tintOpacity: MornyeTheme.chromeOpacity(context),
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
LiquidGlassTabBar.withImpeller(
|
||||
width: constraints.maxWidth,
|
||||
height: 64,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
selectedIndex: selectedIndex,
|
||||
onChanged: onSelected,
|
||||
style: LiquidGlassTabBar.defaultStyle.copyWith(
|
||||
// The frosted base owns the subtle outline. Disable the
|
||||
// package's default specular rim around the whole capsule.
|
||||
shape: const LiquidGlassShape.continuousRoundedRectangle(
|
||||
cornerRadius: 32,
|
||||
borderWidth: 0,
|
||||
lightIntensity: 0,
|
||||
),
|
||||
// The sibling surface already supplies tint and blur. Keep
|
||||
// the lens clear so the moving pill can refract the icons.
|
||||
appearance: const LiquidGlassAppearance(),
|
||||
refraction: const LiquidGlassRefraction(
|
||||
distortion: 0,
|
||||
chromaticAberration: 0,
|
||||
),
|
||||
),
|
||||
itemStyle: LiquidGlassTabItemStyle(
|
||||
selectedColor: scheme.primary,
|
||||
unselectedColor: scheme.onSurface,
|
||||
iconSize: 25,
|
||||
labelFontSize: 11,
|
||||
),
|
||||
pillStyle: const LiquidGlassTabPillStyle(
|
||||
mode: LiquidGlassPillMode.impellerOnly,
|
||||
animated: true,
|
||||
// Keep the moving refractive pill, without stacking the
|
||||
// package's second magnifier lens beneath it.
|
||||
magnifierPill: LiquidGlassTabMagnifierPillStyle(
|
||||
enabled: false,
|
||||
),
|
||||
),
|
||||
items: [
|
||||
for (final destination in destinations)
|
||||
LiquidGlassTabBarItem(
|
||||
label: destination.label,
|
||||
iconBuilder: (context, icon) => IconTheme(
|
||||
data: IconThemeData(
|
||||
size: icon.size,
|
||||
color: icon.selected
|
||||
? scheme.primary
|
||||
: inactiveIconColor,
|
||||
),
|
||||
child: destination.icon,
|
||||
),
|
||||
labelBuilder: (context, label) => Text(
|
||||
destination.label,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
fontSize: label.textStyle.fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return MornyeGlass.navigation(
|
||||
blurEnabled: blurEnabled,
|
||||
strongTint: true,
|
||||
tintOpacity: MornyeTheme.chromeOpacity(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var index = 0; index < destinations.length; index++)
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
selected: index == selectedIndex,
|
||||
button: true,
|
||||
label: destinations[index].label,
|
||||
excludeSemantics: true,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
onTap: () => onSelected(index),
|
||||
child: AnimatedContainer(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 220),
|
||||
constraints: const BoxConstraints(minHeight: 54),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: index == selectedIndex
|
||||
? scheme.onSurface.withValues(alpha: 0.09)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconTheme(
|
||||
data: IconThemeData(
|
||||
size: 25,
|
||||
color: index == selectedIndex
|
||||
? scheme.primary
|
||||
: inactiveIconColor,
|
||||
),
|
||||
// Keep tab selection quiet, as in Mornye. Badges
|
||||
// stay live without the Material bounce/spin.
|
||||
child: destinations[index].icon,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
destinations[index].label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
|
||||
/// Bounds of the tapped control or row, in the root overlay's coordinates.
|
||||
Rect? mornyeMenuAnchor(BuildContext context) {
|
||||
final box = context.findRenderObject();
|
||||
if (box is! RenderBox || !box.hasSize) return null;
|
||||
if (box.size.height > MediaQuery.sizeOf(context).height * 0.65) return null;
|
||||
return box.localToGlobal(Offset.zero) & box.size;
|
||||
}
|
||||
|
||||
Future<T?> showMornyeContextMenu<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
Rect? anchor,
|
||||
}) {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
final themes = InheritedTheme.capture(from: context, to: navigator.context);
|
||||
final target = anchor ?? mornyeMenuAnchor(context);
|
||||
return navigator.push<T>(
|
||||
RawDialogRoute<T>(
|
||||
barrierColor: Colors.black.withValues(alpha: 0.12),
|
||||
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
||||
transitionDuration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 160),
|
||||
// Opacity layers isolate a BackdropFilter from the underlying artwork.
|
||||
transitionBuilder: (_, _, _, child) => child,
|
||||
pageBuilder: (context, animation, _) {
|
||||
final media = MediaQuery.of(context);
|
||||
return Semantics(
|
||||
scopesRoute: true,
|
||||
namesRoute: true,
|
||||
label: MaterialLocalizations.of(context).popupMenuLabel,
|
||||
explicitChildNodes: true,
|
||||
child: CustomSingleChildLayout(
|
||||
delegate: _MenuLayout(
|
||||
anchor: target,
|
||||
padding: media.padding.copyWith(
|
||||
bottom: math.max(media.padding.bottom, media.viewInsets.bottom),
|
||||
),
|
||||
),
|
||||
child: ScaleTransition(
|
||||
scale: animation.drive(Tween<double>(begin: 0.96, end: 1)),
|
||||
child: themes.wrap(Builder(builder: builder)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _MenuLayout extends SingleChildLayoutDelegate {
|
||||
const _MenuLayout({required this.anchor, required this.padding});
|
||||
|
||||
final Rect? anchor;
|
||||
final EdgeInsets padding;
|
||||
static const double _margin = 16;
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
final width = math.min(
|
||||
320.0,
|
||||
math.max(0.0, constraints.maxWidth - padding.horizontal - _margin * 2),
|
||||
);
|
||||
return BoxConstraints(
|
||||
minWidth: width,
|
||||
maxWidth: width,
|
||||
maxHeight: math.max(
|
||||
0,
|
||||
constraints.maxHeight - padding.vertical - _margin * 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
final left = padding.left + _margin;
|
||||
final top = padding.top + _margin;
|
||||
final right = math.max(
|
||||
left,
|
||||
size.width - padding.right - _margin - childSize.width,
|
||||
);
|
||||
final bottom = math.max(
|
||||
top,
|
||||
size.height - padding.bottom - _margin - childSize.height,
|
||||
);
|
||||
final x = anchor == null
|
||||
? (size.width - childSize.width) / 2
|
||||
: anchor!.right - childSize.width;
|
||||
var y = anchor == null ? size.height * 0.18 : anchor!.bottom + 8;
|
||||
if (anchor != null && y > bottom) {
|
||||
y = anchor!.top - childSize.height - 8;
|
||||
if (y < top) y = bottom;
|
||||
}
|
||||
return Offset(x.clamp(left, right), y.clamp(top, bottom));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_MenuLayout oldDelegate) =>
|
||||
anchor != oldDelegate.anchor || padding != oldDelegate.padding;
|
||||
}
|
||||
|
||||
class MornyeMenuAction {
|
||||
const MornyeMenuAction({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.subtitle,
|
||||
this.selected = false,
|
||||
this.destructive = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String? subtitle;
|
||||
final VoidCallback? onPressed;
|
||||
final bool selected;
|
||||
final bool destructive;
|
||||
}
|
||||
|
||||
/// One frosted popover, with compact shortcuts and separated action groups.
|
||||
class MornyeContextMenu extends StatelessWidget {
|
||||
const MornyeContextMenu({
|
||||
super.key,
|
||||
this.quickActions = const [],
|
||||
required this.groups,
|
||||
this.inheritSurface = false,
|
||||
});
|
||||
|
||||
final List<MornyeMenuAction> quickActions;
|
||||
final List<List<MornyeMenuAction>> groups;
|
||||
final bool inheritSurface;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = inheritSurface
|
||||
? Theme.of(context)
|
||||
: MornyeTheme.build(Theme.of(context).brightness);
|
||||
final visibleGroups = groups.where((group) => group.isNotEmpty).toList();
|
||||
final shortcuts = quickActions.isEmpty
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final action in quickActions)
|
||||
Expanded(child: _action(theme, action, compact: true)),
|
||||
],
|
||||
),
|
||||
);
|
||||
final actions = Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < visibleGroups.length; i++) ...[
|
||||
if (i > 0 || quickActions.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: i == 0 ? 0 : 8),
|
||||
child: const Divider(
|
||||
height: 0.5,
|
||||
thickness: 0.5,
|
||||
indent: 20,
|
||||
endIndent: 20,
|
||||
),
|
||||
),
|
||||
for (final action in visibleGroups[i]) _action(theme, action),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
return Theme(
|
||||
data: theme,
|
||||
child: MornyeGlassPanel.overlay(
|
||||
radius: 28,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Let the entire menu scroll when pinning the shortcuts would
|
||||
// leave too little room for a row in landscape or large text.
|
||||
if (constraints.maxHeight < 360 ||
|
||||
MediaQuery.textScalerOf(context).scale(17) > 28) {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [?shortcuts, actions],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
?shortcuts,
|
||||
Flexible(child: SingleChildScrollView(child: actions)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _action(
|
||||
ThemeData theme,
|
||||
MornyeMenuAction action, {
|
||||
bool compact = false,
|
||||
}) {
|
||||
final color = action.onPressed == null
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: action.destructive || action.selected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface;
|
||||
final icon = Icon(action.icon, size: compact ? 23 : 21, color: color);
|
||||
final label = Text(
|
||||
action.label,
|
||||
textAlign: compact ? TextAlign.center : TextAlign.start,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: color,
|
||||
fontSize: compact ? 13 : 17,
|
||||
fontWeight: compact ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
);
|
||||
return Semantics(
|
||||
selected: action.selected,
|
||||
child: CupertinoButton(
|
||||
padding: compact
|
||||
? const EdgeInsets.symmetric(horizontal: 4, vertical: 10)
|
||||
: const EdgeInsets.symmetric(horizontal: 20, vertical: 13),
|
||||
onPressed: action.onPressed,
|
||||
child: compact
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [icon, const SizedBox(height: 7), label],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
label,
|
||||
if (action.subtitle != null) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
action.subtitle!,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
/// Landscape keeps the cover in place while the right pane switches between
|
||||
/// lyrics, the queue, and controls revealed by a tap.
|
||||
class MornyeLandscapePlayer extends StatefulWidget {
|
||||
const MornyeLandscapePlayer({
|
||||
super.key,
|
||||
required this.artwork,
|
||||
required this.header,
|
||||
required this.lyrics,
|
||||
required this.queue,
|
||||
required this.controls,
|
||||
required this.volume,
|
||||
});
|
||||
|
||||
final Widget artwork;
|
||||
final Widget header;
|
||||
final Widget lyrics;
|
||||
final Widget queue;
|
||||
final Widget controls;
|
||||
final Widget volume;
|
||||
|
||||
@override
|
||||
State<MornyeLandscapePlayer> createState() => _MornyeLandscapePlayerState();
|
||||
}
|
||||
|
||||
class _MornyeLandscapePlayerState extends State<MornyeLandscapePlayer> {
|
||||
Timer? _hideTimer;
|
||||
bool _controlsVisible = false;
|
||||
bool _queueVisible = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (MediaQuery.accessibleNavigationOf(context)) {
|
||||
_controlsVisible = true;
|
||||
_hideTimer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleHide() {
|
||||
_hideTimer?.cancel();
|
||||
if (!_controlsVisible || MediaQuery.accessibleNavigationOf(context)) return;
|
||||
_hideTimer = Timer(const Duration(seconds: 4), () {
|
||||
if (!mounted) return;
|
||||
if (ModalRoute.of(context)?.isCurrent == false) {
|
||||
_scheduleHide();
|
||||
return;
|
||||
}
|
||||
setState(() => _controlsVisible = false);
|
||||
});
|
||||
}
|
||||
|
||||
void _reveal() {
|
||||
if (!_controlsVisible) setState(() => _controlsVisible = true);
|
||||
_scheduleHide();
|
||||
}
|
||||
|
||||
void _showPanel({required bool queue}) {
|
||||
_hideTimer?.cancel();
|
||||
setState(() {
|
||||
_queueVisible = queue;
|
||||
_controlsVisible = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hideTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Listener(
|
||||
onPointerDown: (_) => _hideTimer?.cancel(),
|
||||
onPointerUp: (_) => _scheduleHide(),
|
||||
onPointerCancel: (_) => _scheduleHide(),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: _reveal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: widget.artwork),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: widget.header,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => AnimatedSwitcher(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 180),
|
||||
layoutBuilder: (current, previous) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
for (final child in previous)
|
||||
IgnorePointer(
|
||||
child: ExcludeSemantics(child: child),
|
||||
),
|
||||
?current,
|
||||
],
|
||||
),
|
||||
child: _controlsVisible
|
||||
? SingleChildScrollView(
|
||||
key: const ValueKey('landscape-controls'),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
widget.controls,
|
||||
widget.volume,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: context
|
||||
.l10n
|
||||
.nowPlayingTabLyrics,
|
||||
icon: const Icon(
|
||||
CupertinoIcons.quote_bubble,
|
||||
),
|
||||
onPressed: () =>
|
||||
_showPanel(queue: false),
|
||||
),
|
||||
IconButton(
|
||||
tooltip:
|
||||
context.l10n.nowPlayingUpNext,
|
||||
icon: const Icon(
|
||||
CupertinoIcons.list_bullet,
|
||||
),
|
||||
onPressed: () =>
|
||||
_showPanel(queue: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: KeyedSubtree(
|
||||
key: ValueKey(_queueVisible),
|
||||
child: _queueVisible
|
||||
? widget.queue
|
||||
: widget.lyrics,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Mornye TrackInfoView's vertically stacked label and value.
|
||||
class MornyeMetadataRow extends StatelessWidget {
|
||||
const MornyeMetadataRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.showDivider = true,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final bool showDivider;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final content = Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(value, style: theme.textTheme.bodyLarge),
|
||||
if (showDivider) ...[
|
||||
const SizedBox(height: 7),
|
||||
const Divider(height: 0.5),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
if (onTap == null && onLongPress == null) return content;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// The press response and advancing skip glyphs used by Mornye's transport.
|
||||
class MornyePlaybackButton extends StatefulWidget {
|
||||
const MornyePlaybackButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.color,
|
||||
required this.onPressed,
|
||||
this.iconSize = 40,
|
||||
this.padding = const EdgeInsets.all(8),
|
||||
this.loading = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final Color color;
|
||||
final VoidCallback? onPressed;
|
||||
final double iconSize;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final bool loading;
|
||||
|
||||
@override
|
||||
State<MornyePlaybackButton> createState() => _MornyePlaybackButtonState();
|
||||
}
|
||||
|
||||
class _MornyePlaybackButtonState extends State<MornyePlaybackButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _states = WidgetStatesController();
|
||||
late final _skip = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
bool get _isSkip =>
|
||||
widget.icon == CupertinoIcons.forward_fill ||
|
||||
widget.icon == CupertinoIcons.backward_fill;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_states.dispose();
|
||||
_skip.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _skipGlyph(bool reduceMotion) => AnimatedBuilder(
|
||||
animation: _skip,
|
||||
builder: (context, _) {
|
||||
if (!_skip.isAnimating || reduceMotion) return Icon(widget.icon);
|
||||
final progress = Curves.easeInOut.transform(_skip.value);
|
||||
final size = widget.iconSize;
|
||||
Widget triangle(double position, double scale) => Positioned(
|
||||
left: size * (position - 0.26),
|
||||
top: 0,
|
||||
width: size * 0.52,
|
||||
height: size,
|
||||
child: Transform.scale(
|
||||
scale: scale,
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.fill,
|
||||
child: Icon(CupertinoIcons.play_fill),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Transform.flip(
|
||||
flipX: widget.icon == CupertinoIcons.backward_fill,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
triangle(0.25, progress),
|
||||
triangle(0.25 + progress * 0.5, 1),
|
||||
triangle(0.75, 1 - progress),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final motion = Duration(milliseconds: reduceMotion ? 120 : 180);
|
||||
final enabled = widget.onPressed != null && !widget.loading;
|
||||
return IconButton(
|
||||
statesController: _states,
|
||||
tooltip: widget.tooltip,
|
||||
color: widget.color,
|
||||
iconSize: widget.iconSize,
|
||||
padding: widget.padding,
|
||||
style: ButtonStyle(
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
overlayColor: WidgetStateProperty.resolveWith((states) {
|
||||
return states.contains(WidgetState.focused) ||
|
||||
states.contains(WidgetState.hovered)
|
||||
? widget.color.withValues(alpha: 0.08)
|
||||
: Colors.transparent;
|
||||
}),
|
||||
),
|
||||
onPressed: enabled
|
||||
? () {
|
||||
if (_isSkip && !reduceMotion) _skip.forward(from: 0);
|
||||
widget.onPressed!();
|
||||
}
|
||||
: null,
|
||||
icon: ValueListenableBuilder<Set<WidgetState>>(
|
||||
valueListenable: _states,
|
||||
builder: (context, states, _) {
|
||||
final pressed = states.contains(WidgetState.pressed);
|
||||
return AnimatedScale(
|
||||
scale: pressed && !reduceMotion ? 0.95 : 1,
|
||||
duration: motion,
|
||||
curve: Curves.easeOutCubic,
|
||||
child: AnimatedOpacity(
|
||||
opacity: pressed ? 0.72 : 1,
|
||||
duration: motion,
|
||||
child: SizedBox.square(
|
||||
dimension: widget.iconSize,
|
||||
child: AnimatedSwitcher(
|
||||
duration: Duration(milliseconds: reduceMotion ? 120 : 200),
|
||||
child: widget.loading
|
||||
? const Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 32,
|
||||
child: CircularProgressIndicator(strokeWidth: 3),
|
||||
),
|
||||
)
|
||||
: KeyedSubtree(
|
||||
key: ValueKey(widget.icon),
|
||||
child: _isSkip
|
||||
? _skipGlyph(reduceMotion)
|
||||
: Icon(widget.icon),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
/// Rolls only the changed digits, keeping the timeline labels steady in width.
|
||||
class MornyePlaybackTime extends StatefulWidget {
|
||||
const MornyePlaybackTime({
|
||||
super.key,
|
||||
required this.seconds,
|
||||
required this.style,
|
||||
this.remaining = false,
|
||||
});
|
||||
|
||||
final int seconds;
|
||||
final TextStyle? style;
|
||||
final bool remaining;
|
||||
|
||||
@override
|
||||
State<MornyePlaybackTime> createState() => _MornyePlaybackTimeState();
|
||||
}
|
||||
|
||||
class _MornyePlaybackTimeState extends State<MornyePlaybackTime> {
|
||||
bool _increasing = true;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MornyePlaybackTime oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.seconds != oldWidget.seconds) {
|
||||
_increasing = widget.seconds > oldWidget.seconds;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final clock = formatClock(widget.seconds);
|
||||
final label = '${widget.remaining ? '-' : ''}$clock';
|
||||
final style = (widget.style ?? const TextStyle()).copyWith(
|
||||
fontFeatures: [
|
||||
...?widget.style?.fontFeatures,
|
||||
const FontFeature.tabularFigures(),
|
||||
],
|
||||
);
|
||||
if (MediaQuery.disableAnimationsOf(context)) {
|
||||
return Text(label, style: style);
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
label: label,
|
||||
excludeSemantics: true,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
textDirection: TextDirection.ltr,
|
||||
children: [
|
||||
if (widget.remaining) Text('-', style: style),
|
||||
for (var index = 0; index < clock.length; index++)
|
||||
ClipRect(
|
||||
// Anchor digit slots from the seconds end when minutes grow.
|
||||
key: ValueKey(clock.length - index),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 280),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) {
|
||||
final entering = child.key == ValueKey(clock[index]);
|
||||
final direction = _increasing ? 0.7 : -0.7;
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: Offset(0, entering ? direction : -direction),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
clock[index],
|
||||
key: ValueKey(clock[index]),
|
||||
style: style,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/theme/cover_palette.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
|
||||
|
||||
/// Navigation for the title/artist, separate from playback and file actions.
|
||||
class MornyePlayerNavigationMenu extends StatelessWidget {
|
||||
const MornyePlayerNavigationMenu({super.key, required this.mediaItem});
|
||||
|
||||
final MediaItem mediaItem;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = MornyeTheme.build(Brightness.dark);
|
||||
final art = mediaItem.artUri;
|
||||
return Theme(
|
||||
data: theme.copyWith(
|
||||
colorScheme: theme.colorScheme.copyWith(primary: Colors.grey),
|
||||
),
|
||||
child: CoverPaletteBuilder(
|
||||
imageSource: art?.scheme == 'file'
|
||||
? art!.toFilePath()
|
||||
: art?.toString(),
|
||||
builder: (context, palette) {
|
||||
final dominant = HSLColor.fromColor(palette.primary);
|
||||
final surface = dominant
|
||||
.withSaturation(dominant.saturation.clamp(0.0, 0.28))
|
||||
.withLightness(0.28)
|
||||
.toColor();
|
||||
return Theme(
|
||||
data: theme.copyWith(
|
||||
colorScheme: theme.colorScheme.copyWith(
|
||||
surfaceContainerHigh: surface,
|
||||
),
|
||||
),
|
||||
child: MornyeContextMenu(
|
||||
inheritSurface: true,
|
||||
groups: [
|
||||
[
|
||||
if ((mediaItem.artist ?? '').trim().isNotEmpty)
|
||||
MornyeMenuAction(
|
||||
icon: CupertinoIcons.mic,
|
||||
label: context.l10n.mornyeGoToArtist,
|
||||
subtitle: mediaItem.artist,
|
||||
onPressed: () => Navigator.of(context).pop('artist'),
|
||||
),
|
||||
if ((mediaItem.album ?? '').trim().isNotEmpty)
|
||||
MornyeMenuAction(
|
||||
icon: CupertinoIcons.square_stack,
|
||||
label: context.l10n.homeGoToAlbum,
|
||||
subtitle: mediaItem.album,
|
||||
onPressed: () => Navigator.of(context).pop('album'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A floating menu that inherits the player's dark appearance.
|
||||
class MornyePlayerActionsSheet extends StatelessWidget {
|
||||
const MornyePlayerActionsSheet({
|
||||
super.key,
|
||||
required this.mediaItem,
|
||||
this.sleepTimerSubtitle,
|
||||
});
|
||||
|
||||
final MediaItem mediaItem;
|
||||
final String? sleepTimerSubtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => MornyeContextMenu(
|
||||
groups: [
|
||||
[
|
||||
if ((mediaItem.album ?? '').trim().isNotEmpty)
|
||||
_action(
|
||||
context,
|
||||
'album',
|
||||
context.l10n.homeGoToAlbum,
|
||||
CupertinoIcons.square_stack,
|
||||
),
|
||||
_action(
|
||||
context,
|
||||
'details',
|
||||
context.l10n.nowPlayingDetails,
|
||||
CupertinoIcons.info,
|
||||
),
|
||||
],
|
||||
[
|
||||
_action(
|
||||
context,
|
||||
'sleepTimer',
|
||||
context.l10n.nowPlayingSleepTimer,
|
||||
CupertinoIcons.moon_zzz,
|
||||
subtitle: sleepTimerSubtitle,
|
||||
),
|
||||
_action(
|
||||
context,
|
||||
'external',
|
||||
context.l10n.nowPlayingOpenInExternalPlayer,
|
||||
CupertinoIcons.arrow_up_right_square,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
MornyeMenuAction _action(
|
||||
BuildContext context,
|
||||
String value,
|
||||
String label,
|
||||
IconData icon, {
|
||||
String? subtitle,
|
||||
}) => MornyeMenuAction(
|
||||
icon: icon,
|
||||
label: label,
|
||||
subtitle: subtitle,
|
||||
onPressed: () => Navigator.of(context).pop(value),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
|
||||
class MornyePlayerArtwork extends StatelessWidget {
|
||||
const MornyePlayerArtwork({
|
||||
super.key,
|
||||
required this.mediaItem,
|
||||
this.videoUrl,
|
||||
});
|
||||
|
||||
final MediaItem mediaItem;
|
||||
final String? videoUrl;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fallback = PlayerArtwork(
|
||||
artUri: mediaItem.artUri?.toString(),
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
cacheWidth:
|
||||
(MediaQuery.sizeOf(context).width *
|
||||
MediaQuery.devicePixelRatioOf(context))
|
||||
.round(),
|
||||
);
|
||||
if (MediaQuery.disableAnimationsOf(context)) return fallback;
|
||||
return videoUrl == null
|
||||
? fallback
|
||||
: MotionHeaderBanner(
|
||||
key: ValueKey(videoUrl),
|
||||
videoUrl: videoUrl!,
|
||||
fallback: fallback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/cover_palette.dart';
|
||||
|
||||
/// Opaque cover-derived surface, with full-bleed artwork on the player page
|
||||
/// and a muted two-tone gradient behind lyrics and the queue.
|
||||
class MornyePlayerBackground extends StatelessWidget {
|
||||
const MornyePlayerBackground({
|
||||
super.key,
|
||||
required this.artUri,
|
||||
this.artwork,
|
||||
this.squareArtwork = true,
|
||||
});
|
||||
|
||||
final Uri? artUri;
|
||||
final Widget? artwork;
|
||||
final bool squareArtwork;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final source = artUri?.scheme == 'file'
|
||||
? artUri!.toFilePath()
|
||||
: artUri?.toString();
|
||||
|
||||
return Theme(
|
||||
// Missing or unreadable covers use neutral gray, not the app's accent.
|
||||
data: theme.copyWith(
|
||||
colorScheme: theme.colorScheme.copyWith(
|
||||
brightness: Brightness.dark,
|
||||
primary: const Color(0xff808080),
|
||||
),
|
||||
),
|
||||
child: CoverPaletteBuilder(
|
||||
imageSource: source,
|
||||
builder: (context, scheme) {
|
||||
final dominant = HSLColor.fromColor(scheme.primary);
|
||||
final muted = dominant.withSaturation(
|
||||
dominant.saturation.clamp(0.0, 0.28),
|
||||
);
|
||||
final motion = MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 380);
|
||||
return AnimatedContainer(
|
||||
duration: motion,
|
||||
curve: Curves.easeInOutCubic,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
muted.withLightness(artwork == null ? 0.28 : 0.10).toColor(),
|
||||
muted.withLightness(artwork == null ? 0.15 : 0.10).toColor(),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: squareArtwork
|
||||
? MediaQuery.sizeOf(context).width
|
||||
: MediaQuery.sizeOf(context).height * 0.66,
|
||||
child: ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: [0, 0.58, 1],
|
||||
colors: [Colors.white, Colors.white, Colors.transparent],
|
||||
).createShader(bounds),
|
||||
child: HeroMode(
|
||||
// The compact header owns the route hero while the
|
||||
// full cover is fading out behind the lyrics.
|
||||
enabled: artwork != null,
|
||||
child: AnimatedSwitcher(
|
||||
duration: motion,
|
||||
switchInCurve: Curves.easeInOutCubic,
|
||||
switchOutCurve: Curves.easeInOutCubic,
|
||||
layoutBuilder: (current, previous) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
for (final child in previous)
|
||||
HeroMode(enabled: false, child: child),
|
||||
?current,
|
||||
],
|
||||
),
|
||||
child: artwork == null
|
||||
? null
|
||||
: KeyedSubtree(
|
||||
key: const ValueKey('full-player-artwork'),
|
||||
child: artwork!,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_metadata_row.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
|
||||
/// Uses Mornye's TrackInfoView layout: artwork header, then selectable values
|
||||
/// below small labels, separated by thin dividers on one glass surface.
|
||||
class MornyePlayerDetailsSheet extends ConsumerWidget {
|
||||
const MornyePlayerDetailsSheet({
|
||||
super.key,
|
||||
required this.rows,
|
||||
required this.scrollController,
|
||||
this.mediaItem,
|
||||
});
|
||||
|
||||
final List<(String, String)> rows;
|
||||
final ScrollController scrollController;
|
||||
final MediaItem? mediaItem;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final item = mediaItem;
|
||||
final blur =
|
||||
!MediaQuery.highContrastOf(context) &&
|
||||
(!ref.watch(lowEndDeviceProvider) ||
|
||||
ref.watch(backdropBlurEnabledProvider));
|
||||
return MornyeGlass.navigation(
|
||||
blurEnabled: blur,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
const AppSheetHandle(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.nowPlayingDetails,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
context.l10n.dialogDone,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SelectionArea(
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
if (item != null)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 18, 24, 18),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox.square(
|
||||
dimension: 70,
|
||||
child: PlayerArtwork(
|
||||
artUri: item.artUri?.toString(),
|
||||
colorScheme: theme.colorScheme,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
item.artist ?? '',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyLarge
|
||||
?.copyWith(
|
||||
color: theme
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (rows.isEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text(
|
||||
context.l10n.nowPlayingNoMetadata,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final (label, value) = rows[index];
|
||||
return MornyeMetadataRow(
|
||||
label: label,
|
||||
value: value,
|
||||
showDivider: index < rows.length - 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_playback_button.dart';
|
||||
|
||||
class MornyePlayerFavoriteButton extends ConsumerStatefulWidget {
|
||||
const MornyePlayerFavoriteButton({
|
||||
super.key,
|
||||
required this.mediaItem,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final MediaItem mediaItem;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
ConsumerState<MornyePlayerFavoriteButton> createState() =>
|
||||
_MornyePlayerFavoriteButtonState();
|
||||
}
|
||||
|
||||
class _MornyePlayerFavoriteButtonState
|
||||
extends ConsumerState<MornyePlayerFavoriteButton> {
|
||||
bool _saving = false;
|
||||
|
||||
Future<void> _toggle() async {
|
||||
if (_saving) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final provider = playerCollectionTrackProvider(widget.mediaItem);
|
||||
if (ref.read(provider).hasError) ref.invalidate(provider);
|
||||
final track = await ref.read(provider.future);
|
||||
if (!mounted) return;
|
||||
await ref.read(libraryCollectionsProvider.notifier).toggleLoved(track);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarError(context.friendlyError(error)),
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final track = ref
|
||||
.watch(playerCollectionTrackProvider(widget.mediaItem))
|
||||
.value;
|
||||
final loved = ref.watch(
|
||||
libraryCollectionsProvider.select(
|
||||
(state) => track != null && state.isLoved(track),
|
||||
),
|
||||
);
|
||||
return Semantics(
|
||||
toggled: loved,
|
||||
child: MornyePlaybackButton(
|
||||
icon: loved ? CupertinoIcons.star_fill : CupertinoIcons.star,
|
||||
tooltip: loved
|
||||
? context.l10n.trackOptionRemoveFromLoved
|
||||
: context.l10n.trackOptionAddToLoved,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
iconSize: widget.compact ? 20 : 24,
|
||||
onPressed: _saving ? null : _toggle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
|
||||
|
||||
/// Upcoming tracks share the player's artwork backdrop and transport controls.
|
||||
class MornyePlayerQueue extends ConsumerWidget {
|
||||
const MornyePlayerQueue({
|
||||
super.key,
|
||||
required this.colorScheme,
|
||||
required this.onShuffleLibrary,
|
||||
});
|
||||
|
||||
final ColorScheme colorScheme;
|
||||
final VoidCallback onShuffleLibrary;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final queue = ref.watch(playQueueProvider).value ?? const <MediaItem>[];
|
||||
final currentId = ref.watch(
|
||||
currentMediaItemProvider.select((item) => item.value?.id),
|
||||
);
|
||||
final playback = ref.watch(
|
||||
playbackStateProvider.select(
|
||||
(state) => (
|
||||
index: state.value?.queueIndex,
|
||||
shuffle: state.value?.shuffleMode == AudioServiceShuffleMode.all,
|
||||
repeat: state.value?.repeatMode ?? AudioServiceRepeatMode.none,
|
||||
),
|
||||
),
|
||||
);
|
||||
final reportedIndex = playback.index;
|
||||
final currentIndex =
|
||||
reportedIndex != null &&
|
||||
reportedIndex >= 0 &&
|
||||
reportedIndex < queue.length &&
|
||||
queue[reportedIndex].id == currentId
|
||||
? reportedIndex
|
||||
: queue.indexWhere((item) => item.id == currentId);
|
||||
final start = currentIndex + 1;
|
||||
final count = queue.length - start;
|
||||
final controller = ref.read(musicPlayerControllerProvider);
|
||||
final type = Theme.of(context).textTheme;
|
||||
final repeatLabel = switch (playback.repeat) {
|
||||
AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne,
|
||||
AudioServiceRepeatMode.none => context.l10n.nowPlayingRepeatOff,
|
||||
_ => context.l10n.nowPlayingRepeatAll,
|
||||
};
|
||||
|
||||
Widget modeButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required bool selected,
|
||||
required VoidCallback onPressed,
|
||||
}) => Expanded(
|
||||
child: Semantics(
|
||||
selected: selected,
|
||||
child: Tooltip(
|
||||
message: label,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white.withValues(
|
||||
alpha: selected ? 0.3 : 0.12,
|
||||
),
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(0, 44),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: Icon(icon, semanticLabel: label, size: 24),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return CustomScrollView(
|
||||
key: const PageStorageKey('mornye-player-queue'),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
modeButton(
|
||||
icon: CupertinoIcons.shuffle,
|
||||
label: playback.shuffle
|
||||
? context.l10n.nowPlayingShuffleOn
|
||||
: context.l10n.nowPlayingPlayInOrder,
|
||||
selected: playback.shuffle,
|
||||
onPressed: () => controller.setShuffle(!playback.shuffle),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
modeButton(
|
||||
icon: playback.repeat == AudioServiceRepeatMode.one
|
||||
? CupertinoIcons.repeat_1
|
||||
: CupertinoIcons.repeat,
|
||||
label: repeatLabel,
|
||||
selected: playback.repeat != AudioServiceRepeatMode.none,
|
||||
onPressed: () =>
|
||||
controller.setRepeatMode(switch (playback.repeat) {
|
||||
AudioServiceRepeatMode.none =>
|
||||
AudioServiceRepeatMode.all,
|
||||
AudioServiceRepeatMode.all =>
|
||||
AudioServiceRepeatMode.one,
|
||||
_ => AudioServiceRepeatMode.none,
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.mornyeContinuePlaying,
|
||||
style: type.titleLarge?.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (buttonContext) => IconButton(
|
||||
tooltip: MaterialLocalizations.of(
|
||||
context,
|
||||
).moreButtonTooltip,
|
||||
icon: const Icon(
|
||||
CupertinoIcons.ellipsis,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: () => showMornyeContextMenu<void>(
|
||||
context: buttonContext,
|
||||
builder: (menuContext) => MornyeContextMenu(
|
||||
groups: [
|
||||
[
|
||||
MornyeMenuAction(
|
||||
icon: CupertinoIcons.shuffle,
|
||||
label: context.l10n.nowPlayingShuffleLibrary,
|
||||
onPressed: () {
|
||||
Navigator.pop(menuContext);
|
||||
onShuffleLibrary();
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (count == 0)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(28),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text(
|
||||
context.l10n.nowPlayingQueueEmpty,
|
||||
style: type.bodyMedium?.copyWith(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: count,
|
||||
onReorderItem: (oldIndex, newIndex) =>
|
||||
controller.moveQueueItem(start + oldIndex, start + newIndex),
|
||||
proxyDecorator: (child, index, animation) => Material(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: child,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final item = queue[start + index];
|
||||
return ListTile(
|
||||
key: ValueKey('${item.id}_${start + index}'),
|
||||
contentPadding: const EdgeInsets.only(left: 12),
|
||||
minVerticalPadding: 6,
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: SizedBox.square(
|
||||
dimension: 40,
|
||||
child: PlayerArtwork(
|
||||
artUri: item.artUri?.toString(),
|
||||
colorScheme: colorScheme,
|
||||
cacheWidth: 120,
|
||||
iconSize: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: type.bodyLarge?.copyWith(color: Colors.white),
|
||||
),
|
||||
subtitle: Text(
|
||||
item.artist ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: type.bodySmall?.copyWith(color: Colors.white70),
|
||||
),
|
||||
trailing: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const ColoredBox(
|
||||
color: Colors.transparent,
|
||||
child: SizedBox.square(
|
||||
dimension: 44,
|
||||
child: Icon(
|
||||
CupertinoIcons.line_horizontal_3,
|
||||
color: Colors.white38,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () => controller.jumpTo(start + index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A continuous player track with no thumb in either gesture state.
|
||||
class MornyePlayerSlider extends StatelessWidget {
|
||||
const MornyePlayerSlider({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.max = 1,
|
||||
this.activeColor = Colors.white,
|
||||
this.inactiveColor = const Color(0x2EFFFFFF),
|
||||
this.onChangeStart,
|
||||
this.onChangeEnd,
|
||||
});
|
||||
|
||||
final double value;
|
||||
final double max;
|
||||
final Color activeColor;
|
||||
final Color inactiveColor;
|
||||
final ValueChanged<double> onChanged;
|
||||
final ValueChanged<double>? onChangeStart;
|
||||
final ValueChanged<double>? onChangeEnd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 44,
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 6,
|
||||
trackGap: 0,
|
||||
trackShape: const RoundedRectSliderTrackShape(),
|
||||
thumbShape: SliderComponentShape.noThumb,
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
showValueIndicator: ShowValueIndicator.never,
|
||||
),
|
||||
child: Slider(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
value: value,
|
||||
max: max,
|
||||
activeColor: activeColor,
|
||||
inactiveColor: inactiveColor,
|
||||
onChanged: onChanged,
|
||||
onChangeStart: onChangeStart,
|
||||
onChangeEnd: onChangeEnd,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_player_slider.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:volume_controller/volume_controller.dart';
|
||||
|
||||
/// A single shared subscription for the visible player, including hardware
|
||||
/// volume-button changes. This controls system volume, not ReplayGain gain.
|
||||
final systemVolumeProvider = StreamProvider.autoDispose<double>((ref) {
|
||||
final events = StreamController<double>();
|
||||
final volume = VolumeController.instance;
|
||||
final subscription = volume.addListener(events.add);
|
||||
subscription.onError((Object error, StackTrace stack) {
|
||||
events.addError(error, stack);
|
||||
});
|
||||
ref.onDispose(() {
|
||||
unawaited(subscription.cancel());
|
||||
volume.removeListener();
|
||||
unawaited(events.close());
|
||||
});
|
||||
return events.stream;
|
||||
});
|
||||
|
||||
final systemVolumeWriterProvider = Provider<Future<void> Function(double)>((
|
||||
ref,
|
||||
) {
|
||||
return (value) {
|
||||
final volume = VolumeController.instance;
|
||||
volume.showSystemUI = false;
|
||||
return volume.setVolume(value.clamp(0, 1));
|
||||
};
|
||||
});
|
||||
|
||||
class MornyeVolumeControl extends ConsumerStatefulWidget {
|
||||
const MornyeVolumeControl({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MornyeVolumeControl> createState() =>
|
||||
_MornyeVolumeControlState();
|
||||
}
|
||||
|
||||
class _MornyeVolumeControlState extends ConsumerState<MornyeVolumeControl> {
|
||||
double? _preview;
|
||||
double? _pendingVolume;
|
||||
bool _dragging = false;
|
||||
bool _writing = false;
|
||||
|
||||
void _setVolume(double value) {
|
||||
setState(() => _preview = value);
|
||||
_pendingVolume = value;
|
||||
if (!_writing) unawaited(_flushVolume());
|
||||
}
|
||||
|
||||
Future<void> _flushVolume() async {
|
||||
_writing = true;
|
||||
final write = ref.read(systemVolumeWriterProvider);
|
||||
try {
|
||||
// Send changes during the gesture. If the platform is still processing
|
||||
// a write, keep only the newest value instead of queuing stale positions.
|
||||
while (mounted && _pendingVolume != null) {
|
||||
final value = _pendingVolume!;
|
||||
_pendingVolume = null;
|
||||
try {
|
||||
await write(value);
|
||||
} catch (error) {
|
||||
AppLogger('PlayerVolume').w('Could not set system volume: $error');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_writing = false;
|
||||
if (mounted && !_dragging) setState(() => _preview = null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final volume = ref.watch(systemVolumeProvider).value;
|
||||
final value = (_preview ?? volume ?? 0).clamp(0.0, 1.0);
|
||||
String percentage(double volume) => '${(volume * 100).round()}%';
|
||||
return Semantics(
|
||||
label: context.l10n.nowPlayingVolume,
|
||||
slider: true,
|
||||
enabled: volume != null,
|
||||
excludeSemantics: true,
|
||||
value: volume == null ? null : percentage(value),
|
||||
increasedValue: volume == null
|
||||
? null
|
||||
: percentage((value + 0.05).clamp(0, 1)),
|
||||
decreasedValue: volume == null
|
||||
? null
|
||||
: percentage((value - 0.05).clamp(0, 1)),
|
||||
onIncrease: volume == null
|
||||
? null
|
||||
: () => _setVolume((value + 0.05).clamp(0, 1)),
|
||||
onDecrease: volume == null
|
||||
? null
|
||||
: () => _setVolume((value - 0.05).clamp(0, 1)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
CupertinoIcons.speaker_fill,
|
||||
size: 16,
|
||||
color: Colors.white54,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => IgnorePointer(
|
||||
ignoring: volume == null,
|
||||
child: Opacity(
|
||||
opacity: volume == null ? 0.4 : 1,
|
||||
child: MornyePlayerSlider(
|
||||
value: value,
|
||||
activeColor: Colors.white70,
|
||||
inactiveColor: Colors.white12,
|
||||
onChangeStart: (_) => _dragging = true,
|
||||
onChanged: _setVolume,
|
||||
onChangeEnd: (value) {
|
||||
_dragging = false;
|
||||
_setVolume(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Icon(
|
||||
CupertinoIcons.speaker_3_fill,
|
||||
size: 16,
|
||||
color: Colors.white54,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,9 @@ class _MotionHeaderBannerState extends State<MotionHeaderBanner>
|
||||
|
||||
final controller = VideoPlayerController.networkUrl(
|
||||
Uri.parse(url),
|
||||
formatHint: VideoFormat.hls,
|
||||
formatHint: Uri.parse(url).path.toLowerCase().endsWith('.m3u8')
|
||||
? VideoFormat.hls
|
||||
: null,
|
||||
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
|
||||
);
|
||||
_controller = controller;
|
||||
|
||||
@@ -140,7 +140,7 @@ class _OpenOnPlatformSheetState extends State<OpenOnPlatformSheet> {
|
||||
SettingsGroup(
|
||||
children: [
|
||||
for (final entry in entries)
|
||||
ListTile(
|
||||
AppSheetOption(
|
||||
title: Text(_displayName(entry.key)),
|
||||
trailing: Icon(
|
||||
Icons.open_in_new,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_player_slider.dart';
|
||||
|
||||
/// Previews scrubbing locally and seeks once when the gesture ends.
|
||||
class PlaybackSeekSlider extends StatefulWidget {
|
||||
@@ -37,6 +39,43 @@ class _PlaybackSeekSliderState extends State<PlaybackSeekSlider> {
|
||||
final durationMs = widget.duration.inMilliseconds;
|
||||
final enabled = durationMs > 0;
|
||||
final maxMs = enabled ? durationMs.toDouble() : 1.0;
|
||||
if (context.isMornye) {
|
||||
final currentMs = (_previewMs ?? widget.position.inMilliseconds)
|
||||
.clamp(0, maxMs)
|
||||
.toDouble();
|
||||
String percentage(double milliseconds) =>
|
||||
'${(milliseconds / maxMs * 100).clamp(0, 100).round()}%';
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => Semantics(
|
||||
slider: true,
|
||||
enabled: enabled,
|
||||
excludeSemantics: true,
|
||||
value: percentage(currentMs),
|
||||
increasedValue: enabled
|
||||
? percentage((currentMs + 5000).clamp(0, maxMs).toDouble())
|
||||
: null,
|
||||
decreasedValue: enabled
|
||||
? percentage((currentMs - 5000).clamp(0, maxMs).toDouble())
|
||||
: null,
|
||||
onIncrease: enabled
|
||||
? () => _commit((currentMs + 5000).clamp(0, maxMs).toDouble())
|
||||
: null,
|
||||
onDecrease: enabled
|
||||
? () => _commit((currentMs - 5000).clamp(0, maxMs).toDouble())
|
||||
: null,
|
||||
child: IgnorePointer(
|
||||
ignoring: !enabled,
|
||||
child: MornyePlayerSlider(
|
||||
value: enabled ? currentMs : 0,
|
||||
max: maxMs,
|
||||
onChangeStart: (_) => _gestureGeneration++,
|
||||
onChanged: (value) => setState(() => _previewMs = value),
|
||||
onChangeEnd: _commit,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Slider(
|
||||
value: enabled
|
||||
? (_previewMs ?? widget.position.inMilliseconds.toDouble()).clamp(
|
||||
|
||||
@@ -2,9 +2,11 @@ import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
class PlayerArtwork extends StatelessWidget {
|
||||
/// An HTTP(S) URL, file URI, or absolute local artwork path.
|
||||
final String? artUri;
|
||||
final ColorScheme colorScheme;
|
||||
final int? cacheWidth;
|
||||
@@ -44,8 +46,10 @@ class PlayerArtwork extends StatelessWidget {
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
if (uri.startsWith('file://')) {
|
||||
final path = Uri.parse(uri).toFilePath();
|
||||
if (uri.startsWith('file://') || p.isAbsolute(uri)) {
|
||||
final path = uri.startsWith('file://')
|
||||
? Uri.parse(uri).toFilePath()
|
||||
: uri;
|
||||
return Image.file(
|
||||
File(path),
|
||||
fit: BoxFit.cover,
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/cupertino.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/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/app_action_button.dart';
|
||||
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
|
||||
Future<void> showAddTrackToPlaylistSheet(
|
||||
BuildContext context,
|
||||
@@ -28,13 +34,17 @@ Future<void> showAddTracksToPlaylistSheet(
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
showDragHandle: true,
|
||||
showDragHandle: !context.isMornye,
|
||||
backgroundColor: context.isMornye ? Colors.transparent : null,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetContext) {
|
||||
return _PlaylistPickerSheetContent(
|
||||
final content = _PlaylistPickerSheetContent(
|
||||
tracks: tracks,
|
||||
playlistNamePrefill: playlistNamePrefill,
|
||||
);
|
||||
return sheetContext.isMornye
|
||||
? MornyeGlassPanel(tintOpacity: 0.78, child: content)
|
||||
: content;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -111,8 +121,7 @@ class _PlaylistPickerSheetContentState
|
||||
final track = widget.tracks.first;
|
||||
subtitle = '${track.name} • ${track.artistName}';
|
||||
} else {
|
||||
subtitle =
|
||||
'${widget.tracks.length} ${widget.tracks.length == 1 ? 'track' : 'tracks'}';
|
||||
subtitle = context.l10n.tracksCount(widget.tracks.length);
|
||||
}
|
||||
|
||||
final resolvedPlaylists = playlistSummariesValue.asData?.value ?? const [];
|
||||
@@ -124,18 +133,59 @@ class _PlaylistPickerSheetContentState
|
||||
final idsToAdd = _selectedPlaylistIds.difference(effectiveDisabledIds);
|
||||
final hasNewSelections = idsToAdd.isNotEmpty;
|
||||
|
||||
void onDone() {
|
||||
if (hasNewSelections) {
|
||||
_handleDone(resolvedPlaylists);
|
||||
} else {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.playlist_add),
|
||||
title: Text(context.l10n.collectionAddToPlaylist),
|
||||
subtitle: Text(subtitle),
|
||||
),
|
||||
if (context.isMornye) ...[
|
||||
const AppSheetHandle(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.collectionAddToPlaylist,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
] else
|
||||
ListTile(
|
||||
leading: const Icon(Icons.playlist_add),
|
||||
title: Text(context.l10n.collectionAddToPlaylist),
|
||||
subtitle: Text(subtitle),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_circle_outline),
|
||||
_PlaylistPickerRow(
|
||||
leading: Icon(
|
||||
context.isMornye
|
||||
? CupertinoIcons.add_circled
|
||||
: Icons.add_circle_outline,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
title: Text(context.l10n.collectionCreatePlaylist),
|
||||
onTap: () async {
|
||||
final name = await _promptPlaylistName(
|
||||
@@ -147,6 +197,7 @@ class _PlaylistPickerSheetContentState
|
||||
}
|
||||
final playlistId = await notifier.createPlaylist(name.trim());
|
||||
await notifier.addTracksToPlaylist(playlistId, widget.tracks);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_committedPlaylistIds.add(playlistId);
|
||||
_selectedPlaylistIds.remove(playlistId);
|
||||
@@ -188,10 +239,10 @@ class _PlaylistPickerSheetContentState
|
||||
_selectedPlaylistIds.contains(playlist.id) ||
|
||||
isAlreadyIn;
|
||||
|
||||
return ListTile(
|
||||
return _PlaylistPickerRow(
|
||||
leading: _PlaylistPickerThumbnail(
|
||||
playlist: playlist,
|
||||
isSelected: isSelected,
|
||||
isSelected: !context.isMornye && isSelected,
|
||||
),
|
||||
title: Text(playlist.name),
|
||||
subtitle: Text(
|
||||
@@ -200,6 +251,7 @@ class _PlaylistPickerSheetContentState
|
||||
),
|
||||
),
|
||||
enabled: !isAlreadyIn,
|
||||
selected: isSelected,
|
||||
onTap: !isAlreadyIn
|
||||
? () {
|
||||
setState(() {
|
||||
@@ -241,16 +293,16 @@ class _PlaylistPickerSheetContentState
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
if (hasNewSelections) {
|
||||
_handleDone(resolvedPlaylists);
|
||||
} else {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Text(context.l10n.dialogDone),
|
||||
),
|
||||
child: context.isMornye
|
||||
? AppActionButton(
|
||||
onPressed: onDone,
|
||||
icon: const Icon(CupertinoIcons.checkmark),
|
||||
label: Text(context.l10n.dialogDone),
|
||||
)
|
||||
: FilledButton(
|
||||
onPressed: onDone,
|
||||
child: Text(context.l10n.dialogDone),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -266,10 +318,10 @@ Future<String?> _promptPlaylistName(
|
||||
final controller = TextEditingController(text: playlistNamePrefill);
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
final result = await showDialog<String>(
|
||||
final result = await showAppDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
return AppAlertDialog(
|
||||
title: Text(dialogContext.l10n.collectionCreatePlaylist),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
@@ -294,11 +346,13 @@ Future<String?> _promptPlaylistName(
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
AppDialogAction(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text(dialogContext.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
AppDialogAction(
|
||||
filled: true,
|
||||
isDefault: true,
|
||||
onPressed: () {
|
||||
if (formKey.currentState?.validate() != true) return;
|
||||
Navigator.of(dialogContext).pop(controller.text.trim());
|
||||
@@ -313,6 +367,92 @@ Future<String?> _promptPlaylistName(
|
||||
return result;
|
||||
}
|
||||
|
||||
class _PlaylistPickerRow extends StatelessWidget {
|
||||
const _PlaylistPickerRow({
|
||||
required this.leading,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.enabled = true,
|
||||
this.selected,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Widget leading;
|
||||
final Widget title;
|
||||
final Widget? subtitle;
|
||||
final bool enabled;
|
||||
final bool? selected;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!context.isMornye) {
|
||||
return ListTile(
|
||||
leading: leading,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
enabled: enabled,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
return Semantics(
|
||||
selected: selected,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
onPressed: enabled ? onTap : null,
|
||||
child: Opacity(
|
||||
opacity: enabled ? 1 : 0.5,
|
||||
child: Row(
|
||||
children: [
|
||||
leading,
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DefaultTextStyle(
|
||||
style: theme.textTheme.bodyLarge!,
|
||||
child: title,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 3),
|
||||
DefaultTextStyle(
|
||||
style: theme.textTheme.bodyMedium!.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: subtitle!,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (selected != null) ...[
|
||||
const SizedBox(width: 14),
|
||||
Icon(
|
||||
selected!
|
||||
? CupertinoIcons.checkmark_circle_fill
|
||||
: CupertinoIcons.circle,
|
||||
size: 24,
|
||||
color: selected!
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 0.5, thickness: 0.5, indent: 20, endIndent: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaylistPickerThumbnail extends StatelessWidget {
|
||||
final PlaylistPickerSummary playlist;
|
||||
final bool isSelected;
|
||||
@@ -383,7 +523,7 @@ class _PlaylistPickerThumbnail extends StatelessWidget {
|
||||
cacheWidth: (size * MediaQuery.devicePixelRatioOf(context)).round(),
|
||||
cacheHeight: (size * MediaQuery.devicePixelRatioOf(context)).round(),
|
||||
filterQuality: FilterQuality.low,
|
||||
errorBuilder: (_, _, _) => _iconFallback(colorScheme, size),
|
||||
errorBuilder: (_, _, _) => _iconFallback(context, colorScheme, size),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -394,14 +534,18 @@ class _PlaylistPickerThumbnail extends StatelessWidget {
|
||||
width: size,
|
||||
height: size,
|
||||
networkCacheWidth: (size * 2).toInt(),
|
||||
placeholder: (_) => _iconFallback(colorScheme, size),
|
||||
placeholder: (_) => _iconFallback(context, colorScheme, size),
|
||||
);
|
||||
}
|
||||
|
||||
return _iconFallback(colorScheme, size);
|
||||
return _iconFallback(context, colorScheme, size);
|
||||
}
|
||||
|
||||
Widget _iconFallback(ColorScheme colorScheme, double size) {
|
||||
Widget _iconFallback(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
double size,
|
||||
) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -409,7 +553,10 @@ class _PlaylistPickerThumbnail extends StatelessWidget {
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.queue_music, color: colorScheme.onSurfaceVariant),
|
||||
child: Icon(
|
||||
context.isMornye ? CupertinoIcons.music_note_list : Icons.queue_music,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_icons.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/app_action_button.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
@@ -156,50 +160,34 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsGroup(
|
||||
_options(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.fingerprint),
|
||||
title: Text(l10n.trackReEnrichModeIsrc),
|
||||
subtitle: Text(l10n.trackReEnrichModeIsrcSubtitle),
|
||||
trailing: _mode == ReEnrichBatchMode.isrcOnly
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () =>
|
||||
setState(() => _mode = ReEnrichBatchMode.isrcOnly),
|
||||
_modeOption(
|
||||
icon: Icons.fingerprint,
|
||||
title: l10n.trackReEnrichModeIsrc,
|
||||
subtitle: l10n.trackReEnrichModeIsrcSubtitle,
|
||||
mode: ReEnrichBatchMode.isrcOnly,
|
||||
),
|
||||
const Divider(height: 1, indent: 56),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.playlist_add_check),
|
||||
title: Text(l10n.trackReEnrichModeMissing),
|
||||
subtitle: Text(l10n.trackReEnrichModeMissingSubtitle),
|
||||
trailing: _mode == ReEnrichBatchMode.missingOnly
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () =>
|
||||
setState(() => _mode = ReEnrichBatchMode.missingOnly),
|
||||
_modeOption(
|
||||
icon: Icons.playlist_add_check,
|
||||
title: l10n.trackReEnrichModeMissing,
|
||||
subtitle: l10n.trackReEnrichModeMissingSubtitle,
|
||||
mode: ReEnrichBatchMode.missingOnly,
|
||||
),
|
||||
const Divider(height: 1, indent: 56),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.tune),
|
||||
title: Text(l10n.trackReEnrichModeReplace),
|
||||
subtitle: Text(l10n.trackReEnrichModeReplaceSubtitle),
|
||||
trailing: _mode == ReEnrichBatchMode.selectedFields
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () =>
|
||||
setState(() => _mode = ReEnrichBatchMode.selectedFields),
|
||||
_modeOption(
|
||||
icon: Icons.tune,
|
||||
title: l10n.trackReEnrichModeReplace,
|
||||
subtitle: l10n.trackReEnrichModeReplaceSubtitle,
|
||||
mode: ReEnrichBatchMode.selectedFields,
|
||||
),
|
||||
const Divider(height: 1, indent: 56),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit_note),
|
||||
title: Text(l10n.trackReEnrichModeManual),
|
||||
subtitle: Text(l10n.trackReEnrichModeManualSubtitle),
|
||||
trailing: _mode == ReEnrichBatchMode.manualValues
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () =>
|
||||
setState(() => _mode = ReEnrichBatchMode.manualValues),
|
||||
_modeOption(
|
||||
icon: Icons.edit_note,
|
||||
title: l10n.trackReEnrichModeManual,
|
||||
subtitle: l10n.trackReEnrichModeManualSubtitle,
|
||||
mode: ReEnrichBatchMode.manualValues,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -215,26 +203,20 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsGroup(
|
||||
_options(
|
||||
children: [
|
||||
CheckboxListTile(
|
||||
title: Text(
|
||||
l10n.trackReEnrichSelectAll,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
_fieldToggle(
|
||||
title: l10n.trackReEnrichSelectAll,
|
||||
value: _allSelected,
|
||||
tristate: true,
|
||||
onChanged: _toggleAll,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
for (final field in ReEnrichFields.all) ...[
|
||||
const Divider(height: 1, indent: 56),
|
||||
CheckboxListTile(
|
||||
secondary: Icon(_iconFor(field), size: 20),
|
||||
title: Text(_labelFor(field, l10n)),
|
||||
_fieldToggle(
|
||||
icon: _iconFor(field),
|
||||
title: _labelFor(field, l10n),
|
||||
value: _selected.contains(field),
|
||||
onChanged: (value) => _toggle(field, value),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -264,7 +246,8 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
|
||||
],
|
||||
),
|
||||
),
|
||||
SettingsGroup(
|
||||
_options(
|
||||
grouped: false,
|
||||
children: [
|
||||
for (
|
||||
var index = 0;
|
||||
@@ -274,19 +257,50 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
|
||||
if (index > 0) const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
|
||||
child: TextField(
|
||||
controller:
|
||||
_manualControllers[manualBatchMetadataFields[index]],
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: _manualLabelFor(
|
||||
manualBatchMetadataFields[index],
|
||||
l10n,
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
child: context.isMornye
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_manualLabelFor(
|
||||
manualBatchMetadataFields[index],
|
||||
l10n,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
CupertinoTextField(
|
||||
controller:
|
||||
_manualControllers[manualBatchMetadataFields[index]],
|
||||
onChanged: (_) => setState(() {}),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: MornyeTheme.controlFill(context),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: TextField(
|
||||
controller:
|
||||
_manualControllers[manualBatchMetadataFields[index]],
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: _manualLabelFor(
|
||||
manualBatchMetadataFields[index],
|
||||
l10n,
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -297,7 +311,7 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
child: AppActionButton(
|
||||
onPressed:
|
||||
(_mode == ReEnrichBatchMode.selectedFields &&
|
||||
_selected.isEmpty) ||
|
||||
@@ -322,4 +336,126 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _options({required List<Widget> children, bool grouped = true}) {
|
||||
if (!context.isMornye) return SettingsGroup(children: children);
|
||||
final content = Column(mainAxisSize: MainAxisSize.min, children: children);
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: grouped ? 16 : 4, vertical: 4),
|
||||
child: grouped
|
||||
? Material(
|
||||
color: MornyeTheme.controlFill(context),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: content,
|
||||
)
|
||||
: content,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _modeOption({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required ReEnrichBatchMode mode,
|
||||
}) {
|
||||
void select() => setState(() => _mode = mode);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
if (!context.isMornye) {
|
||||
return ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: _mode == mode
|
||||
? Icon(Icons.check, color: scheme.primary)
|
||||
: null,
|
||||
onTap: select,
|
||||
);
|
||||
}
|
||||
return Semantics(
|
||||
selected: _mode == mode,
|
||||
child: CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
onPressed: select,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(mornyeIconFor(icon), color: scheme.primary, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.bodyLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 20,
|
||||
child: _mode == mode
|
||||
? Icon(
|
||||
CupertinoIcons.checkmark,
|
||||
color: scheme.primary,
|
||||
size: 20,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fieldToggle({
|
||||
required String title,
|
||||
required bool value,
|
||||
required ValueChanged<bool?> onChanged,
|
||||
IconData? icon,
|
||||
}) {
|
||||
if (!context.isMornye) {
|
||||
return CheckboxListTile(
|
||||
title: Text(title),
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
secondary: icon == null ? null : Icon(icon, size: 20),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
);
|
||||
}
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Semantics(
|
||||
checked: value,
|
||||
child: CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
onPressed: () => onChanged(!value),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
value
|
||||
? CupertinoIcons.check_mark_circled_solid
|
||||
: CupertinoIcons.circle,
|
||||
color: value ? scheme.primary : scheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(title, style: Theme.of(context).textTheme.bodyLarge),
|
||||
),
|
||||
if (icon != null)
|
||||
Icon(
|
||||
mornyeIconFor(icon),
|
||||
color: scheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/app_action_button.dart';
|
||||
import 'package:spotiflac_android/widgets/app_content_card.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
|
||||
Future<bool> showReEnrichReviewSheet(
|
||||
BuildContext context, {
|
||||
@@ -108,70 +111,79 @@ class _ReEnrichReviewContent extends StatelessWidget {
|
||||
itemCount: visible.length,
|
||||
itemBuilder: (context, index) {
|
||||
final preview = visible[index];
|
||||
return SettingsGroup(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.audio_file_outlined),
|
||||
title: Text(
|
||||
preview.item.trackName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
preview.item.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
final children = <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.audio_file_outlined),
|
||||
title: Text(
|
||||
preview.item.trackName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
for (final change in preview.changes) ...[
|
||||
const Divider(height: 1, indent: 56),
|
||||
Semantics(
|
||||
label:
|
||||
'${_fieldLabel(context, change.field)}: ${_oldValue(context, change)}, ${_newValue(context, change)}',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(56, 10, 16, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_fieldLabel(context, change.field),
|
||||
style: Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(
|
||||
subtitle: Text(
|
||||
preview.item.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
for (final change in preview.changes) ...[
|
||||
const Divider(height: 1, indent: 56),
|
||||
Semantics(
|
||||
label:
|
||||
'${_fieldLabel(context, change.field)}: ${_oldValue(context, change)}, ${_newValue(context, change)}',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(56, 10, 16, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_fieldLabel(context, change.field),
|
||||
style: Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: _oldValue(context, change),
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' → ',
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: _newValue(context, change),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: _oldValue(context, change),
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' → ',
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: _newValue(context, change),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
];
|
||||
return context.isMornye
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
child: AppContentCard(
|
||||
child: Column(children: children),
|
||||
),
|
||||
)
|
||||
: SettingsGroup(children: children);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -179,7 +191,7 @@ class _ReEnrichReviewContent extends StatelessWidget {
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
child: AppActionButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
icon: const Icon(Icons.save_outlined),
|
||||
label: Text(context.l10n.trackReEnrichApplyChanges),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/app_action_button.dart';
|
||||
|
||||
/// Icon+label pill used in selection-mode bottom bars. Disabled state dims
|
||||
/// both the fill and the content to 50% alpha.
|
||||
@@ -19,6 +21,16 @@ class SelectionActionButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (context.isMornye) {
|
||||
return AppActionButton(
|
||||
outlined: true,
|
||||
tonal: true,
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon),
|
||||
label: Text(label, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
fontSize: 15,
|
||||
);
|
||||
}
|
||||
final isDisabled = onPressed == null;
|
||||
return Semantics(
|
||||
button: true,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
@@ -225,31 +228,35 @@ class SelectionBottomBar extends StatelessWidget {
|
||||
final tokens = context.tokens;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(tokens.radiusSheet),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.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: [
|
||||
const AppSheetHandle(),
|
||||
final content = SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppSheetHandle(),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (context.isMornye)
|
||||
Tooltip(
|
||||
message: MaterialLocalizations.of(
|
||||
context,
|
||||
).closeButtonTooltip,
|
||||
child: CupertinoButton(
|
||||
color: MornyeTheme.controlFill(context),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
padding: const EdgeInsets.all(12),
|
||||
onPressed: onClose,
|
||||
child: Icon(
|
||||
CupertinoIcons.xmark,
|
||||
color: colorScheme.onSurface,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton.filledTonal(
|
||||
onPressed: onClose,
|
||||
tooltip: MaterialLocalizations.of(
|
||||
@@ -260,30 +267,45 @@ class SelectionBottomBar extends StatelessWidget {
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
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),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.selectionSelected(selectedCount),
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
allSelected
|
||||
? allSelectedLabel ??
|
||||
context.l10n.selectionAllSelected
|
||||
: tapToSelectLabel ??
|
||||
context.l10n.downloadedAlbumTapToSelect,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Text(
|
||||
allSelected
|
||||
? allSelectedLabel ??
|
||||
context.l10n.selectionAllSelected
|
||||
: tapToSelectLabel ??
|
||||
context.l10n.downloadedAlbumTapToSelect,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (context.isMornye)
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
onPressed: onToggleSelectAll,
|
||||
child: Text(
|
||||
allSelected
|
||||
? context.l10n.actionDeselect
|
||||
: context.l10n.actionSelectAll,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
TextButton.icon(
|
||||
onPressed: onToggleSelectAll,
|
||||
icon: Icon(
|
||||
@@ -299,16 +321,34 @@ class SelectionBottomBar extends StatelessWidget {
|
||||
foregroundColor: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
...children,
|
||||
],
|
||||
),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (context.isMornye) {
|
||||
return MornyeGlassPanel.overlay(child: content);
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(tokens.radiusSheet),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.15),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, -4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
+190
-82
@@ -1,7 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
||||
import 'package:spotiflac_android/widgets/app_switch.dart';
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_icons.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
|
||||
/// Background fill for grouped cards, matching the Settings group look. Blends a
|
||||
@@ -9,6 +14,7 @@ import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
/// black) dark themes as well as normal light/dark themes.
|
||||
Color settingsGroupColor(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
if (context.isMornye) return colorScheme.surfaceContainer;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return isDark
|
||||
? Color.alphaBlend(
|
||||
@@ -182,8 +188,14 @@ class _SettingsSearchTargetState extends State<SettingsSearchTarget> {
|
||||
class SettingsGroup extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final bool glass;
|
||||
|
||||
const SettingsGroup({super.key, required this.children, this.margin});
|
||||
const SettingsGroup({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.margin,
|
||||
this.glass = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -193,15 +205,35 @@ class SettingsGroup extends StatelessWidget {
|
||||
final decoration = BoxDecoration(
|
||||
color: cardColor,
|
||||
borderRadius: BorderRadius.circular(context.tokens.radiusCard),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
border: context.isMornye
|
||||
? null
|
||||
: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
);
|
||||
final child = Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: children),
|
||||
);
|
||||
|
||||
if (glass && context.isMornye) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => Padding(
|
||||
padding:
|
||||
margin ??
|
||||
EdgeInsets.symmetric(
|
||||
horizontal:
|
||||
16 +
|
||||
(constraints.hasBoundedWidth
|
||||
? wideInsetForWidth(constraints.maxWidth)
|
||||
: 0),
|
||||
vertical: 4,
|
||||
),
|
||||
child: MornyeGlassPanel(child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Explicit caller margin wins as-is. Otherwise center on wide surfaces
|
||||
// using the incoming constraint (not screen width) so groups nested in an
|
||||
// already clamped box, e.g. a bottom sheet, are not over-inset.
|
||||
@@ -263,64 +295,90 @@ class SettingsItem extends StatelessWidget {
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: context.isMornye ? 16 : 20,
|
||||
vertical: context.isMornye ? 10 : 16,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: context.isMornye ? 28 : 0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
context.isMornye ? mornyeIconFor(icon!) : icon,
|
||||
color: context.isMornye
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
SizedBox(width: context.isMornye ? 12 : 16),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (titleTrailing != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
titleTrailing!,
|
||||
if (titleTrailing != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
titleTrailing!,
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style:
|
||||
(context.isMornye
|
||||
? Theme.of(context).textTheme.bodySmall
|
||||
: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium)
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
trailing!,
|
||||
] else if (onTap != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
trailing!,
|
||||
] else if (onTap != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
context.isMornye
|
||||
? CupertinoIcons.chevron_forward
|
||||
: Icons.chevron_right,
|
||||
size: context.isMornye ? 18 : 24,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: icon != null ? 56 : 20,
|
||||
endIndent: 20,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
thickness: context.isMornye ? 0.5 : 1,
|
||||
indent: context.isMornye
|
||||
? (icon != null ? 52 : 16)
|
||||
: (icon != null ? 56 : 20),
|
||||
endIndent: context.isMornye ? 16 : 20,
|
||||
color: context.isMornye
|
||||
? colorScheme.outlineVariant
|
||||
: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -365,18 +423,23 @@ class SettingsSwitchItem extends StatelessWidget {
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: context.isMornye ? 16 : 20,
|
||||
vertical: context.isMornye ? 8 : 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
context.isMornye ? mornyeIconFor(icon!) : icon,
|
||||
color: isDisabled
|
||||
? colorScheme.outline
|
||||
: context.isMornye
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
SizedBox(width: context.isMornye ? 12 : 16),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -418,8 +481,9 @@ class SettingsSwitchItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Switch(
|
||||
AppSwitch(
|
||||
value: value,
|
||||
semanticLabel: title,
|
||||
onChanged: isDisabled ? null : onChanged,
|
||||
),
|
||||
],
|
||||
@@ -430,10 +494,14 @@ class SettingsSwitchItem extends StatelessWidget {
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: icon != null ? 56 : 20,
|
||||
endIndent: 20,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
thickness: context.isMornye ? 0.5 : 1,
|
||||
indent: context.isMornye
|
||||
? (icon != null ? 52 : 16)
|
||||
: (icon != null ? 56 : 20),
|
||||
endIndent: context.isMornye ? 16 : 20,
|
||||
color: context.isMornye
|
||||
? colorScheme.outlineVariant
|
||||
: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -451,11 +519,19 @@ class SettingsSectionHeader extends StatelessWidget {
|
||||
final content = Padding(
|
||||
padding: const EdgeInsets.fromLTRB(32, 24, 32, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
context.isMornye ? title.toUpperCase() : title,
|
||||
style:
|
||||
(context.isMornye
|
||||
? Theme.of(context).textTheme.bodySmall
|
||||
: Theme.of(context).textTheme.titleSmall)
|
||||
?.copyWith(
|
||||
color: context.isMornye
|
||||
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: context.isMornye
|
||||
? FontWeight.normal
|
||||
: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
return SettingsSearchTarget(label: title, child: content);
|
||||
@@ -500,13 +576,17 @@ class SettingsChoiceChip extends StatelessWidget {
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final borderRadius = BorderRadius.circular(tokens.radiusCover);
|
||||
|
||||
final unselectedColor = isDark
|
||||
final unselectedColor = context.isMornye
|
||||
? Colors.transparent
|
||||
: isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
final foreground = isSelected
|
||||
final foreground = context.isMornye && isSelected
|
||||
? colorScheme.onSurface
|
||||
: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant;
|
||||
|
||||
@@ -528,7 +608,10 @@ class SettingsChoiceChip extends StatelessWidget {
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: foreground),
|
||||
Icon(
|
||||
context.isMornye ? mornyeIconFor(icon!) : icon,
|
||||
color: foreground,
|
||||
),
|
||||
SizedBox(height: tokens.gapXs + 2),
|
||||
labelText,
|
||||
],
|
||||
@@ -537,7 +620,11 @@ class SettingsChoiceChip extends StatelessWidget {
|
||||
content = Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: foreground),
|
||||
Icon(
|
||||
context.isMornye ? mornyeIconFor(icon!) : icon,
|
||||
size: 18,
|
||||
color: foreground,
|
||||
),
|
||||
SizedBox(width: tokens.gapSm),
|
||||
Flexible(child: labelText),
|
||||
],
|
||||
@@ -545,7 +632,11 @@ class SettingsChoiceChip extends StatelessWidget {
|
||||
}
|
||||
|
||||
final chip = Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
color: isSelected
|
||||
? (context.isMornye
|
||||
? colorScheme.surfaceContainerHighest
|
||||
: colorScheme.primaryContainer)
|
||||
: unselectedColor,
|
||||
borderRadius: borderRadius,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
@@ -608,6 +699,7 @@ class SettingsInfoCard extends StatelessWidget {
|
||||
this.tone = SettingsInfoTone.neutral,
|
||||
this.action,
|
||||
this.margin,
|
||||
this.glass = false,
|
||||
});
|
||||
|
||||
final String message;
|
||||
@@ -620,6 +712,9 @@ class SettingsInfoCard extends StatelessWidget {
|
||||
|
||||
final EdgeInsetsGeometry? margin;
|
||||
|
||||
/// Uses the shared glass surface in Mornye without changing Material cards.
|
||||
final bool glass;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.tokens;
|
||||
@@ -641,23 +736,19 @@ class SettingsInfoCard extends StatelessWidget {
|
||||
),
|
||||
};
|
||||
|
||||
return Container(
|
||||
margin:
|
||||
margin ??
|
||||
EdgeInsets.symmetric(
|
||||
horizontal: tokens.gapLg,
|
||||
vertical: tokens.gapXs,
|
||||
),
|
||||
final useGlass = glass && context.isMornye;
|
||||
final textColor = useGlass ? colorScheme.onSurface : foreground;
|
||||
final content = Padding(
|
||||
padding: EdgeInsets.all(tokens.gapLg),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: tokens.borderRadiusCard,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 20, color: foreground),
|
||||
Icon(
|
||||
useGlass ? mornyeIconFor(icon!) : icon,
|
||||
size: 20,
|
||||
color: useGlass ? colorScheme.primary : foreground,
|
||||
),
|
||||
SizedBox(width: tokens.gapMd),
|
||||
],
|
||||
Expanded(
|
||||
@@ -669,7 +760,7 @@ class SettingsInfoCard extends StatelessWidget {
|
||||
Text(
|
||||
title!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: foreground,
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
@@ -677,7 +768,7 @@ class SettingsInfoCard extends StatelessWidget {
|
||||
],
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: foreground),
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: textColor),
|
||||
),
|
||||
if (action != null) ...[
|
||||
SizedBox(height: tokens.gapSm),
|
||||
@@ -689,5 +780,22 @@ class SettingsInfoCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
return Padding(
|
||||
padding:
|
||||
margin ??
|
||||
EdgeInsets.symmetric(
|
||||
horizontal: tokens.gapLg,
|
||||
vertical: tokens.gapXs,
|
||||
),
|
||||
child: useGlass
|
||||
? MornyeGlassPanel.overlay(child: content)
|
||||
: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: tokens.borderRadiusCard,
|
||||
),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
|
||||
/// How a [TrackCard] paints its container.
|
||||
@@ -80,13 +81,13 @@ class TrackCard extends StatelessWidget {
|
||||
final Color? cardColor;
|
||||
if (isSelected) {
|
||||
cardColor = colorScheme.primaryContainer.withValues(alpha: 0.3);
|
||||
} else if (style == TrackCardStyle.flat) {
|
||||
} else if (style == TrackCardStyle.flat || context.isMornye) {
|
||||
cardColor = Colors.transparent;
|
||||
} else {
|
||||
cardColor = null; // Inherits cardTheme.color.
|
||||
}
|
||||
|
||||
return Card(
|
||||
final card = Card(
|
||||
elevation: 0,
|
||||
// The two styles carry their own outer margin so no call site has to
|
||||
// repeat it: flat rows sit tight inside a collection list, filled cards
|
||||
@@ -163,6 +164,20 @@ class TrackCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!context.isMornye) return card;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
card,
|
||||
Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: tokens.coverList + 40,
|
||||
end: 20,
|
||||
),
|
||||
child: const Divider(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -10,6 +12,8 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/utils/local_playback.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_action_policy.dart';
|
||||
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
@@ -31,12 +35,22 @@ class TrackCollectionQuickActions extends ConsumerWidget {
|
||||
bool hasLocalPlaybackCandidate = false,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
if (context.isMornye) {
|
||||
showMornyeContextMenu<void>(
|
||||
context: context,
|
||||
builder: (_) => _TrackOptionsSheet(
|
||||
track: track,
|
||||
hasLocalPlaybackCandidate: hasLocalPlaybackCandidate,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
builder: (sheetContext) => _TrackOptionsSheet(
|
||||
builder: (_) => _TrackOptionsSheet(
|
||||
track: track,
|
||||
hasLocalPlaybackCandidate: hasLocalPlaybackCandidate,
|
||||
),
|
||||
@@ -50,7 +64,7 @@ class TrackCollectionQuickActions extends ConsumerWidget {
|
||||
return IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
context.isMornye ? CupertinoIcons.ellipsis : Icons.more_vert,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
@@ -93,7 +107,111 @@ class _TrackOptionsSheet extends ConsumerWidget {
|
||||
hasLocalPlaybackCandidate: hasLocalPlaybackCandidate,
|
||||
);
|
||||
|
||||
if (context.isMornye) {
|
||||
final l10n = context.l10n;
|
||||
return MornyeContextMenu(
|
||||
quickActions: [
|
||||
MornyeMenuAction(
|
||||
icon: CupertinoIcons.arrow_down_circle_fill,
|
||||
label: l10n.dialogDownload,
|
||||
onPressed: () {
|
||||
final root = Navigator.of(context, rootNavigator: true).context;
|
||||
Navigator.pop(context);
|
||||
downloadSingleTrack(root, ref, track);
|
||||
},
|
||||
),
|
||||
MornyeMenuAction(
|
||||
icon: CupertinoIcons.star_fill,
|
||||
label: isLoved ? l10n.mornyeFavorited : l10n.mornyeFavorite,
|
||||
selected: isLoved,
|
||||
onPressed: () => _toggleCollection(context, ref, wishlist: false),
|
||||
),
|
||||
MornyeMenuAction(
|
||||
icon: CupertinoIcons.share_solid,
|
||||
label: l10n.trackMetadataShare,
|
||||
onPressed: () {
|
||||
final origin =
|
||||
mornyeMenuAnchor(context) ??
|
||||
Rect.fromCenter(
|
||||
center: MediaQuery.sizeOf(context).center(Offset.zero),
|
||||
width: 1,
|
||||
height: 1,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
text: '${track.name} — ${track.artistName}',
|
||||
sharePositionOrigin: origin,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
groups: [
|
||||
[
|
||||
if (qualityVariantAction == QualityVariantMenuAction.playLocal)
|
||||
MornyeMenuAction(
|
||||
icon: CupertinoIcons.play,
|
||||
label: l10n.trackMetadataPlay,
|
||||
onPressed: () => _playLocal(context, ref),
|
||||
),
|
||||
if (qualityVariantAction ==
|
||||
QualityVariantMenuAction.downloadAnotherQuality)
|
||||
MornyeMenuAction(
|
||||
icon: CupertinoIcons.arrow_down_circle,
|
||||
label: l10n.trackOptionDownloadQualityVariant,
|
||||
onPressed: () => _downloadQualityVariant(context, ref),
|
||||
),
|
||||
MornyeMenuAction(
|
||||
icon: CupertinoIcons.square_stack,
|
||||
label: l10n.homeGoToAlbum,
|
||||
onPressed: () => _goToAlbum(context),
|
||||
),
|
||||
],
|
||||
[
|
||||
MornyeMenuAction(
|
||||
icon: CupertinoIcons.text_badge_plus,
|
||||
label: l10n.collectionAddToPlaylist,
|
||||
onPressed: () {
|
||||
final root = Navigator.of(context, rootNavigator: true).context;
|
||||
Navigator.pop(context);
|
||||
showAddTrackToPlaylistSheet(root, ref, track);
|
||||
},
|
||||
),
|
||||
MornyeMenuAction(
|
||||
icon: isInWishlist
|
||||
? CupertinoIcons.checkmark_circle
|
||||
: CupertinoIcons.add_circled,
|
||||
label: isInWishlist
|
||||
? l10n.trackOptionRemoveFromWishlist
|
||||
: l10n.trackOptionAddToWishlist,
|
||||
onPressed: () => _toggleCollection(context, ref, wishlist: true),
|
||||
),
|
||||
],
|
||||
[
|
||||
MornyeMenuAction(
|
||||
icon: CupertinoIcons.doc_on_doc,
|
||||
label: l10n.trackOptionCopyTrackName,
|
||||
onPressed: () => _copyText(context, track.name),
|
||||
),
|
||||
MornyeMenuAction(
|
||||
icon: CupertinoIcons.person,
|
||||
label: l10n.trackOptionCopyArtist,
|
||||
onPressed: () => _copyText(context, track.artistName),
|
||||
),
|
||||
MornyeMenuAction(
|
||||
icon: CupertinoIcons.doc_on_doc,
|
||||
label: l10n.trackOptionCopyTrackAndArtist,
|
||||
onPressed: () =>
|
||||
_copyText(context, '${track.name} - ${track.artistName}'),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.82,
|
||||
@@ -274,8 +392,12 @@ class _TrackOptionsSheet extends ConsumerWidget {
|
||||
icon: Icons.playlist_add,
|
||||
title: context.l10n.collectionAddToPlaylist,
|
||||
onTap: () {
|
||||
final rootContext = Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).context;
|
||||
Navigator.pop(context);
|
||||
showAddTrackToPlaylistSheet(context, ref, track);
|
||||
showAddTrackToPlaylistSheet(rootContext, ref, track);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -293,6 +415,29 @@ class _TrackOptionsSheet extends ConsumerWidget {
|
||||
downloadSingleTrack(rootContext, ref, track, forceQualityPicker: true);
|
||||
}
|
||||
|
||||
Future<void> _toggleCollection(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required bool wishlist,
|
||||
}) async {
|
||||
final root = Navigator.of(context, rootNavigator: true).context;
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
Navigator.pop(context);
|
||||
final added = wishlist
|
||||
? await notifier.toggleWishlist(track)
|
||||
: await notifier.toggleLoved(track);
|
||||
if (!root.mounted) return;
|
||||
final l10n = root.l10n;
|
||||
final message = wishlist
|
||||
? (added
|
||||
? l10n.collectionAddedToWishlist(track.name)
|
||||
: l10n.collectionRemovedFromWishlist(track.name))
|
||||
: (added
|
||||
? l10n.collectionAddedToLoved(track.name)
|
||||
: l10n.collectionRemovedFromLoved(track.name));
|
||||
ScaffoldMessenger.of(root).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
void _copyText(BuildContext context, String text) {
|
||||
final rootContext = Navigator.of(context, rootNavigator: true).context;
|
||||
Navigator.pop(context);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
@@ -30,6 +31,8 @@ void downloadSingleTrack(
|
||||
}
|
||||
|
||||
if (settings.askQualityBeforeDownload || forceQualityPicker) {
|
||||
// A context menu may be disposed before the quality picker is submitted.
|
||||
final container = ProviderScope.containerOf(context, listen: false);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: track.name,
|
||||
@@ -37,7 +40,7 @@ void downloadSingleTrack(
|
||||
coverUrl: track.coverUrl,
|
||||
recommendedService: recommendedService,
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
container
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(
|
||||
track,
|
||||
@@ -82,20 +85,19 @@ void confirmDownloadAllDialog(
|
||||
VoidCallback onConfirm,
|
||||
) {
|
||||
if (trackCount == 0) return;
|
||||
showDialog<void>(
|
||||
showAppDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
final colorScheme = Theme.of(dialogContext).colorScheme;
|
||||
return AlertDialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
return AppAlertDialog(
|
||||
title: Text(context.l10n.dialogDownloadAllTitle),
|
||||
content: Text(context.l10n.dialogDownloadAllMessage(trackCount)),
|
||||
actions: [
|
||||
TextButton(
|
||||
AppDialogAction(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
AppDialogAction(
|
||||
filled: true,
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
onConfirm();
|
||||
|
||||
@@ -23,6 +23,7 @@ class TrackListTile extends ConsumerWidget {
|
||||
final void Function({bool forceQualityPicker}) onDownload;
|
||||
final Widget leading;
|
||||
final bool clickableArtist;
|
||||
final bool showQualityBadges;
|
||||
final bool isSelectionMode;
|
||||
final bool isSelected;
|
||||
final VoidCallback? onToggleSelection;
|
||||
@@ -35,6 +36,7 @@ class TrackListTile extends ConsumerWidget {
|
||||
required this.onDownload,
|
||||
required this.leading,
|
||||
this.clickableArtist = false,
|
||||
this.showQualityBadges = true,
|
||||
this.isSelectionMode = false,
|
||||
this.isSelected = false,
|
||||
this.onToggleSelection,
|
||||
@@ -95,8 +97,8 @@ class TrackListTile extends ConsumerWidget {
|
||||
),
|
||||
badges: [
|
||||
...buildQualityBadges(
|
||||
audioQuality: track.audioQuality,
|
||||
audioModes: track.audioModes,
|
||||
audioQuality: showQualityBadges ? track.audioQuality : null,
|
||||
audioModes: showQualityBadges ? track.audioModes : null,
|
||||
colorScheme: colorScheme,
|
||||
explicit: track.isExplicit,
|
||||
),
|
||||
|
||||
@@ -6,6 +6,9 @@ import 'package:spotiflac_android/services/apk_downloader.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/app_action_button.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
|
||||
class UpdateDialog extends StatefulWidget {
|
||||
final UpdateInfo updateInfo;
|
||||
@@ -133,7 +136,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
|
||||
return PopScope(
|
||||
canPop: !widget.forced,
|
||||
child: Dialog(
|
||||
child: AppDialogSurface(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
child: ConstrainedBox(
|
||||
@@ -198,7 +201,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
color: context.isMornye
|
||||
? MornyeTheme.controlFill(context)
|
||||
: isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
@@ -208,17 +213,23 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
colorScheme.surface,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
border: context.isMornye
|
||||
? null
|
||||
: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_VersionChip(
|
||||
version: AppInfo.displayVersion,
|
||||
label: context.l10n.updateCurrent,
|
||||
colorScheme: colorScheme,
|
||||
Flexible(
|
||||
child: _VersionChip(
|
||||
version: AppInfo.displayVersion,
|
||||
label: context.l10n.updateCurrent,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
@@ -227,11 +238,13 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_VersionChip(
|
||||
version: widget.updateInfo.version,
|
||||
label: context.l10n.updateNew,
|
||||
colorScheme: colorScheme,
|
||||
isNew: true,
|
||||
Flexible(
|
||||
child: _VersionChip(
|
||||
version: widget.updateInfo.version,
|
||||
label: context.l10n.updateNew,
|
||||
colorScheme: colorScheme,
|
||||
isNew: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -242,7 +255,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
color: context.isMornye
|
||||
? MornyeTheme.controlFill(context)
|
||||
: isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
@@ -319,7 +334,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 180),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
color: context.isMornye
|
||||
? MornyeTheme.controlFill(context)
|
||||
: isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
@@ -351,7 +368,8 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
width: double.infinity,
|
||||
child: widget.forced
|
||||
? const SizedBox.shrink()
|
||||
: OutlinedButton(
|
||||
: AppDialogAction(
|
||||
outlined: true,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
@@ -367,7 +385,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
child: AppActionButton(
|
||||
onPressed: _downloadAndInstall,
|
||||
icon: const Icon(Icons.download_rounded, size: 20),
|
||||
label: Text(context.l10n.updateDownloadInstall),
|
||||
@@ -384,7 +402,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
child: AppDialogAction(
|
||||
onPressed: () {
|
||||
widget.onDisableUpdates();
|
||||
Navigator.pop(context);
|
||||
@@ -407,7 +425,8 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
child: AppDialogAction(
|
||||
outlined: true,
|
||||
onPressed: () {
|
||||
widget.onDismiss();
|
||||
Navigator.pop(context);
|
||||
@@ -529,15 +548,20 @@ class _VersionChip extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isNew
|
||||
color: context.isMornye
|
||||
? Colors.transparent
|
||||
: isNew
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'v$version',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: isNew
|
||||
color: context.isMornye && isNew
|
||||
? colorScheme.primary
|
||||
: isNew
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: isNew ? FontWeight.bold : FontWeight.w500,
|
||||
@@ -555,7 +579,7 @@ Future<void> showUpdateDialog(
|
||||
required VoidCallback onDisableUpdates,
|
||||
bool forced = false,
|
||||
}) async {
|
||||
return showDialog(
|
||||
return showAppDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: !forced,
|
||||
builder: (context) => UpdateDialog(
|
||||
|
||||
Reference in new issue
Block a user