mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
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.
328 lines
9.6 KiB
Dart
328 lines
9.6 KiB
Dart
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.
|
|
///
|
|
/// Sheets using Material's `showDragHandle` should not add this handle.
|
|
class AppSheetHandle extends StatelessWidget {
|
|
const AppSheetHandle({super.key, this.margin});
|
|
|
|
/// Overrides the default spacing around the handle. Sheets whose first row
|
|
/// already carries top padding pass [EdgeInsets.zero].
|
|
final EdgeInsetsGeometry? margin;
|
|
|
|
static const double _width = 40;
|
|
static const double _height = 4;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = context.tokens;
|
|
return ExcludeSemantics(
|
|
child: Center(
|
|
child: Container(
|
|
width: _width,
|
|
height: _height,
|
|
margin:
|
|
margin ??
|
|
EdgeInsets.only(top: tokens.gapMd, bottom: tokens.gapSm),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
|
borderRadius: BorderRadius.circular(_height / 2),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A modal-sheet surface whose height and inner scroll position share one drag.
|
|
///
|
|
/// The child must attach the supplied [ScrollController] to its primary
|
|
/// vertical scroll view. Pulling down at the top then moves the whole surface;
|
|
/// releasing either restores it or dismisses it at the minimum extent.
|
|
class AppDraggableSheet extends StatelessWidget {
|
|
const AppDraggableSheet({
|
|
super.key,
|
|
required this.builder,
|
|
this.initialChildSize = 0.88,
|
|
this.minChildSize = 0.25,
|
|
this.maxChildSize = 0.88,
|
|
});
|
|
|
|
final ScrollableWidgetBuilder builder;
|
|
final double initialChildSize;
|
|
final double minChildSize;
|
|
final double maxChildSize;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DraggableScrollableSheet(
|
|
expand: false,
|
|
snap: true,
|
|
shouldCloseOnMinExtent: true,
|
|
initialChildSize: initialChildSize,
|
|
minChildSize: minChildSize,
|
|
maxChildSize: maxChildSize,
|
|
builder: builder,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Standard chrome for modal sheet content: drag handle, optional title block,
|
|
/// height cap, keyboard inset and bottom safe area, so each sheet only supplies
|
|
/// its own body.
|
|
class AppBottomSheet extends StatelessWidget {
|
|
const AppBottomSheet({
|
|
super.key,
|
|
required this.child,
|
|
this.showHandle = true,
|
|
this.title,
|
|
this.subtitle,
|
|
this.maxHeightFactor,
|
|
});
|
|
|
|
final Widget child;
|
|
final bool showHandle;
|
|
|
|
/// Leading title row. Rendered with the app's single sheet-title style
|
|
/// instead of the three variants that had accumulated across sheets.
|
|
final String? title;
|
|
|
|
/// Secondary line under [title], e.g. the track a sheet is acting on.
|
|
final String? subtitle;
|
|
|
|
/// Caps the sheet at this fraction of the screen height.
|
|
final double? maxHeightFactor;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = context.tokens;
|
|
final theme = Theme.of(context);
|
|
|
|
Widget content = Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (showHandle) const AppSheetHandle(),
|
|
if (title != null)
|
|
Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
tokens.gapXl,
|
|
tokens.gapLg,
|
|
tokens.gapXl,
|
|
subtitle == null ? tokens.gapSm : tokens.gapXs,
|
|
),
|
|
child: Text(
|
|
title!,
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
if (subtitle != null)
|
|
Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
tokens.gapXl,
|
|
0,
|
|
tokens.gapXl,
|
|
tokens.gapMd,
|
|
),
|
|
child: Text(
|
|
subtitle!,
|
|
maxLines: context.isMornye ? 3 : 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
Flexible(child: child),
|
|
],
|
|
);
|
|
|
|
if (maxHeightFactor != null) {
|
|
content = ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.sizeOf(context).height * maxHeightFactor!,
|
|
),
|
|
child: content,
|
|
);
|
|
}
|
|
|
|
// Sheets containing text fields must lift above the keyboard; doing it here
|
|
// means no call site has to remember `viewInsets`.
|
|
final keyboardInset = MediaQuery.viewInsetsOf(context).bottom;
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: keyboardInset),
|
|
child: SafeArea(top: false, child: content),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Opens a modal bottom sheet wrapped in [AppBottomSheet].
|
|
///
|
|
/// The shape comes from `ThemeData.bottomSheetTheme`, which is derived from
|
|
/// [AppTokens.radiusSheet]; call sites no longer pass a `shape` and therefore
|
|
/// cannot drift from the scale.
|
|
Future<T?> showAppBottomSheet<T>({
|
|
required BuildContext context,
|
|
required WidgetBuilder builder,
|
|
String? title,
|
|
String? subtitle,
|
|
double? maxHeightFactor,
|
|
bool isScrollControlled = true,
|
|
bool isDismissible = true,
|
|
bool enableDrag = true,
|
|
bool useRootNavigator = false,
|
|
bool showHandle = true,
|
|
Color? backgroundColor,
|
|
}) {
|
|
return showAppModalBottomSheet<T>(
|
|
context: context,
|
|
isScrollControlled: isScrollControlled,
|
|
isDismissible: isDismissible,
|
|
enableDrag: enableDrag,
|
|
useRootNavigator: useRootNavigator,
|
|
backgroundColor: backgroundColor,
|
|
builder: (sheetContext) => AppBottomSheet(
|
|
showHandle: showHandle,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
maxHeightFactor: maxHeightFactor,
|
|
child: builder(sheetContext),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|