Files
SpotiFLAC-Mobile/lib/widgets/app_alert_dialog.dart
T
zarzet 63e1a9b9c8 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.
2026-09-19 12:10:10 +07:00

292 lines
9.1 KiB
Dart

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);
}
}