Files
SpotiFLAC-Mobile/lib/widgets/mornye_chrome.dart
T
zarzet ed80d3e8bb feat(mornye): separate Search into a circular navigation button
Keep Home, Library, and Repo in the glass capsule while Search remains accessible on the right. Preserve compact transitions and allow returning to Home when Search is active.
2026-09-21 18:15:52 +07:00

668 lines
24 KiB
Dart

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.firstInGroup = true,
this.lastInGroup = true,
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.firstInGroup = true,
this.lastInGroup = true,
this.tintOpacity = 0.78,
}) : strongTint = false;
final Widget child;
final double radius;
final bool firstInGroup;
final bool lastInGroup;
final bool strongTint;
final double? tintOpacity;
@override
Widget build(BuildContext context, WidgetRef ref) => MornyeGlass.navigation(
radius: radius,
firstInGroup: firstInGroup,
lastInGroup: lastInGroup,
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,
this.hideHomeIcon = false,
});
final List<NavigationDestination> destinations;
final int selectedIndex;
final ValueChanged<int> onSelected;
final bool blurEnabled;
// Home moves independently while the full tab capsule folds away.
final bool hideHomeIcon;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final selectionFill = scheme.onSurface.withValues(
alpha: scheme.brightness == Brightness.dark ? 0.12 : 0.08,
);
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 < 0 ? 0 : 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: LiquidGlassTabPillStyle(
mode: LiquidGlassPillMode.impellerOnly,
show: selectedIndex >= 0,
color: selectionFill,
animated: true,
// Keep the moving refractive pill, without stacking the
// package's second magnifier lens beneath it.
magnifierPill: const LiquidGlassTabMagnifierPillStyle(
enabled: false,
),
),
items: [
for (final (index, destination) in destinations.indexed)
LiquidGlassTabBarItem(
label: destination.label,
iconBuilder: (context, icon) => IconTheme(
data: IconThemeData(
size: icon.size,
color: icon.selected && selectedIndex >= 0
? scheme.primary
: inactiveIconColor,
),
child: Opacity(
opacity: hideHomeIcon && index == 0 ? 0 : 1,
child: destination.icon,
),
),
labelBuilder: (context, label) => Text(
destination.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
fontSize: label.textStyle.fontSize,
fontWeight: FontWeight.w600,
color: selectedIndex < 0
? scheme.onSurface
: label.textStyle.color,
),
),
),
],
),
// Search lives outside the capsule. With no selected tab the
// package still needs an internal index; handle taps here so
// returning to that index (Home) is not swallowed as a re-tap.
if (selectedIndex < 0)
Positioned(
left: 6,
right: 6,
bottom: 8,
height: 64,
child: Row(
children: [
for (
var index = 0;
index < destinations.length;
index++
)
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onSelected(index),
child: const SizedBox.expand(),
),
),
],
),
),
],
),
),
),
);
}
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
? selectionFill
: 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: Opacity(
opacity: hideHomeIcon && index == 0 ? 0 : 1,
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: index == selectedIndex
? scheme.primary
: scheme.onSurface,
),
),
],
),
),
),
),
),
),
],
),
),
);
}
}