diff --git a/lib/core/config/app_chat_chrome.dart b/lib/core/config/app_chat_chrome.dart index b8f9ca5..137602d 100644 --- a/lib/core/config/app_chat_chrome.dart +++ b/lib/core/config/app_chat_chrome.dart @@ -1,8 +1,14 @@ import 'package:flutter/foundation.dart'; +import '../../frontend/widgets/liquid_glass.dart'; import 'persisted_setting.dart'; -enum ChatChromeStyle { color, blur, none, transparent } +enum ChatChromeStyle { color, blur, none, transparent, liquidGlass } + +class ChatChromeMaterial { + static bool isLiquid(ChatChromeStyle style) => + style == ChatChromeStyle.liquidGlass && LiquidGlass.isSupported; +} class AppChatChrome { static const prefKey = 'app_chat_chrome'; diff --git a/lib/core/config/app_composer_background.dart b/lib/core/config/app_composer_background.dart index ecd14fe..e37b115 100644 --- a/lib/core/config/app_composer_background.dart +++ b/lib/core/config/app_composer_background.dart @@ -1,8 +1,18 @@ import 'package:flutter/foundation.dart'; +import '../../frontend/widgets/liquid_glass.dart'; import 'persisted_setting.dart'; -enum ComposerBackground { standard, frostBlur } +enum ComposerBackground { standard, frostBlur, liquidGlass } + +class ComposerMaterial { + static bool isLiquid(ComposerBackground value) => + value == ComposerBackground.liquidGlass && LiquidGlass.isSupported; + + static bool isFrost(ComposerBackground value) => + value == ComposerBackground.frostBlur || + (value == ComposerBackground.liquidGlass && !LiquidGlass.isSupported); +} class AppComposerBackground { static const prefKey = 'app_composer_background'; diff --git a/lib/core/config/app_liquid_glass.dart b/lib/core/config/app_liquid_glass.dart new file mode 100644 index 0000000..ad8fcd6 --- /dev/null +++ b/lib/core/config/app_liquid_glass.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +class AppLiquidGlass { + static const bool enabled = false; + + static const double blurSigma = 0; + static const double spread = 1; + static const double refraction = 34; + static const double chroma = 0; + static const double specular = 0.45; + static const double rimWidth = 8; + static const Offset light = Offset(-0.4, -1); + static const double tintFeather = 44; + + static Color navTint(ColorScheme cs) => + cs.surfaceContainerHigh.withValues(alpha: 0); + + static Color panelTint(ColorScheme cs) => cs.surface.withValues(alpha: 0.24); +} diff --git a/lib/core/config/app_nav_pill_style.dart b/lib/core/config/app_nav_pill_style.dart index 9adfb3d..57eb9cc 100644 --- a/lib/core/config/app_nav_pill_style.dart +++ b/lib/core/config/app_nav_pill_style.dart @@ -1,8 +1,18 @@ import 'package:flutter/foundation.dart'; +import '../../frontend/widgets/liquid_glass.dart'; import 'persisted_setting.dart'; -enum NavPillStyle { glossy, frostBlur } +enum NavPillStyle { glossy, frostBlur, liquidGlass } + +class NavPillMaterial { + static bool isLiquid(NavPillStyle style) => + style == NavPillStyle.liquidGlass && LiquidGlass.isSupported; + + static bool isFrost(NavPillStyle style) => + style == NavPillStyle.frostBlur || + (style == NavPillStyle.liquidGlass && !LiquidGlass.isSupported); +} class AppNavPillStyle { static const prefKey = 'app_nav_pill_style'; diff --git a/lib/core/config/app_visual_style.dart b/lib/core/config/app_visual_style.dart index 0404f67..e102b35 100644 --- a/lib/core/config/app_visual_style.dart +++ b/lib/core/config/app_visual_style.dart @@ -2,7 +2,11 @@ import 'package:flutter/foundation.dart'; import 'persisted_setting.dart'; -enum VisualStyle { materialYou, glossy } +enum VisualStyle { materialYou, glossy, liquidGlass } + +extension VisualStyleChrome on VisualStyle { + bool get glossyChrome => this != VisualStyle.materialYou; +} class AppVisualStyle { static const prefKey = 'app_visual_style'; diff --git a/lib/frontend/screens/chats/chat/view/chat_header.dart b/lib/frontend/screens/chats/chat/view/chat_header.dart index 74635a9..7e23e24 100644 --- a/lib/frontend/screens/chats/chat/view/chat_header.dart +++ b/lib/frontend/screens/chats/chat/view/chat_header.dart @@ -9,6 +9,7 @@ import 'package:komet/frontend/widgets/online_dot.dart'; class ChatHeaderRow extends StatelessWidget { final bool glossy; final bool frosted; + final bool liquid; final BackdropKey? backdropKey; final ColorScheme cs; final bool embedded; @@ -32,6 +33,7 @@ class ChatHeaderRow extends StatelessWidget { super.key, required this.glossy, required this.frosted, + this.liquid = false, this.backdropKey, required this.cs, required this.embedded, @@ -56,9 +58,9 @@ class ChatHeaderRow extends StatelessWidget { Widget build(BuildContext context) => glossy ? _glossyRow(context) : _materialRow(context); - Color? get _pillColor => frosted ? AppFrost.pillTint(cs) : null; + Color? get _pillColor => frosted || liquid ? AppFrost.pillTint(cs) : null; - double? get _pillBlur => frosted ? AppFrost.sigma : null; + double? get _pillBlur => frosted && !liquid ? AppFrost.sigma : null; Widget _glossyRow(BuildContext context) { return Padding( @@ -73,6 +75,7 @@ class ChatHeaderRow extends StatelessWidget { child: GlossyPill( color: _pillColor, blurSigma: _pillBlur, + liquid: liquid, backdropKey: backdropKey, onTap: () { if (embedded) { @@ -97,6 +100,7 @@ class ChatHeaderRow extends StatelessWidget { child: GlossyPill( color: _pillColor, blurSigma: _pillBlur, + liquid: liquid, backdropKey: backdropKey, onTap: onOpenInfo, padding: const EdgeInsets.fromLTRB(6, 6, 16, 6), @@ -185,6 +189,7 @@ class ChatHeaderRow extends StatelessWidget { GlossyPill( color: _pillColor, blurSigma: _pillBlur, + liquid: liquid, backdropKey: backdropKey, padding: const EdgeInsets.symmetric(horizontal: 2), child: SizedBox( diff --git a/lib/frontend/screens/chats/chat/view/composer_input.dart b/lib/frontend/screens/chats/chat/view/composer_input.dart index 4086575..e3ae06a 100644 --- a/lib/frontend/screens/chats/chat/view/composer_input.dart +++ b/lib/frontend/screens/chats/chat/view/composer_input.dart @@ -15,6 +15,7 @@ import 'package:komet/frontend/screens/chats/chat/upload_status.dart'; import 'package:komet/frontend/screens/chats/chat/video_note_controller.dart'; import 'package:komet/frontend/screens/chats/chat/voice_record_controller.dart'; import 'package:komet/frontend/widgets/glossy_pill.dart'; +import 'package:komet/frontend/widgets/liquid_glass.dart'; import 'package:komet/frontend/widgets/rich_message_controller.dart'; class ComposerInputBar extends StatelessWidget { @@ -418,10 +419,14 @@ class ComposerInputBar extends StatelessWidget { bool get _flat => style == ComposerStyle.materialYou; - bool get _frost => background == ComposerBackground.frostBlur; + bool get _frost => ComposerMaterial.isFrost(background); + + bool get _liquid => ComposerMaterial.isLiquid(background); + + bool get _translucent => _frost || _liquid; Widget _barSurface(ColorScheme cs, Widget child) { - if (!_flat || background == ComposerBackground.frostBlur) return child; + if (!_flat || _translucent) return child; return DecoratedBox( decoration: BoxDecoration( color: cs.surface, @@ -434,13 +439,14 @@ class ComposerInputBar extends StatelessWidget { Widget _fieldSurface(ColorScheme cs, Widget child) { if (_flat) return child; return GlossyPill( - color: _frost + color: _translucent ? AppFrost.inputTint(cs) : Color.alphaBlend( cs.surfaceContainerHighest.withValues(alpha: 0.92), cs.surface, ), blurSigma: _frost ? AppFrost.sigma : null, + liquid: _liquid, backdropKey: backdropKey, borderRadius: BorderRadius.circular(28), depth: 8, @@ -471,6 +477,7 @@ class ComposerInputBar extends StatelessWidget { return GlossyPill( color: color, blurSigma: _frost ? AppFrost.sigma : null, + liquid: _liquid, backdropKey: backdropKey, borderRadius: BorderRadius.circular(27), onTap: onTap, @@ -538,23 +545,14 @@ class ComposerInputBar extends StatelessWidget { ], ), ); - if (_flat && _frost) return row; - if (!_frost && chrome != ChatChromeStyle.transparent) return row; - return ClipRect( - child: BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: AppFrost.sigma, - sigmaY: AppFrost.sigma, - ), - backdropGroupKey: backdropKey, - child: DecoratedBox( - decoration: BoxDecoration( - color: AppFrost.panelTint(cs), - border: Border(top: AppFrost.hairline(cs)), - ), - child: row, - ), - ), + if (_flat && _translucent) return row; + if (!_translucent && chrome != ChatChromeStyle.transparent) return row; + return GlassSurface( + liquid: _liquid, + frostTint: AppFrost.panelTint(cs), + border: Border(top: AppFrost.hairline(cs)), + backdropKey: backdropKey, + child: row, ); }, ); diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index d9b34f7..282c669 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -2033,17 +2033,21 @@ class _ChatListScreenState extends State ValueListenableBuilder( valueListenable: AppNavPillStyle.current, builder: (context, navStyle, child) { + final liquid = + style.glossyChrome && + NavPillMaterial.isLiquid(navStyle); final frost = - style == VisualStyle.glossy && - navStyle == NavPillStyle.frostBlur; + style.glossyChrome && + NavPillMaterial.isFrost(navStyle); return GlossyPill( onTap: _toggleFab, - color: frost + color: frost || liquid ? AppFrost.fabTint(cs) : cs.primaryContainer, blurSigma: frost ? AppFrost.sigma : null, + liquid: liquid, backdropKey: _frostBackdrop, borderRadius: BorderRadius.circular(28), elevated: true, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 8385a87..ffc8617 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -93,6 +93,7 @@ import '../../widgets/schedule_time_picker.dart'; import '../../widgets/chat_wallpaper_sheet.dart'; import '../../widgets/chat_wallpaper_view.dart'; import '../../widgets/glossy_pill.dart'; +import '../../widgets/liquid_glass.dart'; import 'scheduled_messages_screen.dart'; import 'chat_wallpaper_preview_screen.dart'; @@ -129,15 +130,12 @@ class _FrostedPanel extends StatelessWidget { @override Widget build(BuildContext context) { - return ClipRect( - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), - backdropGroupKey: backdropKey, - child: DecoratedBox( - decoration: BoxDecoration(color: tint, border: border), - child: child, - ), - ), + return GlassSurface( + frostTint: tint, + frostSigma: sigma, + border: border, + backdropKey: backdropKey, + child: child, ); } } @@ -474,13 +472,18 @@ class _ChatScreenState extends State ChatWallpaper? _wallpaper; bool get _composerFrosted => - AppComposerBackground.current.value == ComposerBackground.frostBlur; + AppComposerBackground.current.value != ComposerBackground.standard; bool get _composerUnderlap => AppChatChrome.current.value != ChatChromeStyle.color || _composerFrosted; + bool get _liquidChrome => + AppVisualStyle.current.value.glossyChrome && + ChatChromeMaterial.isLiquid(AppChatChrome.current.value); + ChatChromeStyle get _effectiveChrome { final chrome = AppChatChrome.current.value; + if (chrome == ChatChromeStyle.liquidGlass) return ChatChromeStyle.transparent; if (_wallpaper != null && chrome == ChatChromeStyle.none) { return ChatChromeStyle.blur; } @@ -2565,7 +2568,7 @@ class _ChatScreenState extends State } PreferredSizeWidget _buildAppBar(ColorScheme cs) { - final glossy = AppVisualStyle.current.value == VisualStyle.glossy; + final glossy = AppVisualStyle.current.value.glossyChrome; final searchT = Curves.easeOut.transform(_searchAnim.value.clamp(0.0, 1.0)); final height = glossy ? ui.lerpDouble(_glossyHeaderHeight, _glossySearchHeight, searchT)! @@ -2644,6 +2647,7 @@ class _ChatScreenState extends State glossy: glossy, frosted: glossy && chrome == ChatChromeStyle.transparent, + liquid: _liquidChrome, backdropKey: _pillBackdrop, cs: cs, embedded: widget.embedded, @@ -4381,6 +4385,7 @@ class _ChatScreenState extends State isPreview: pinned.pinnedMsgIsPreview, floating: floating, frosted: _effectiveChrome == ChatChromeStyle.transparent, + liquid: _liquidChrome, backdropKey: _pillBackdrop, onTap: _jumpToPinnedMessage, onUnpin: pinned.canPinMessages(_myId) @@ -4437,7 +4442,7 @@ class _ChatScreenState extends State } double _pinnedBannerTop() { - final glossy = AppVisualStyle.current.value == VisualStyle.glossy; + final glossy = AppVisualStyle.current.value.glossyChrome; return MediaQuery.paddingOf(context).top + (glossy ? _glossyHeaderHeight : kToolbarHeight) - _pinnedBannerLift; @@ -4536,7 +4541,7 @@ class _ChatScreenState extends State if (height != null) { resolved = height; } else { - final glossy = AppVisualStyle.current.value == VisualStyle.glossy; + final glossy = AppVisualStyle.current.value.glossyChrome; resolved = MediaQuery.paddingOf(context).top + (glossy ? _glossyHeaderHeight : kToolbarHeight); @@ -4591,7 +4596,7 @@ class _ChatScreenState extends State double _floatingDateTop(double pinnedHeight) { if (AppChatChrome.current.value == ChatChromeStyle.color) { - final glossy = AppVisualStyle.current.value == VisualStyle.glossy; + final glossy = AppVisualStyle.current.value.glossyChrome; return glossy ? 2 : 4; } if (chat?.hasPinnedMessage == true && pinnedHeight > 0) { @@ -4919,8 +4924,9 @@ class _ChatScreenState extends State width: 46, height: 46, child: GlossyPill( - color: frosted ? AppFrost.pillTint(cs) : null, - blurSigma: frosted ? AppFrost.sigma : null, + color: frosted || _liquidChrome ? AppFrost.pillTint(cs) : null, + blurSigma: frosted && !_liquidChrome ? AppFrost.sigma : null, + liquid: _liquidChrome, backdropKey: _pillBackdrop, elevated: true, onTap: _onScrollDownTap, @@ -5980,6 +5986,7 @@ class _PinnedMessageBanner extends StatelessWidget { final VoidCallback? onUnpin; final bool floating; final bool frosted; + final bool liquid; final BackdropKey? backdropKey; const _PinnedMessageBanner({ @@ -5989,6 +5996,7 @@ class _PinnedMessageBanner extends StatelessWidget { this.onUnpin, this.floating = false, this.frosted = false, + this.liquid = false, this.backdropKey, }); @@ -6058,19 +6066,15 @@ class _PinnedMessageBanner extends StatelessWidget { final bottomBorder = Border(bottom: AppFrost.hairline(cs)); if (frosted) { - return ClipRRect( - borderRadius: floating ? BorderRadius.circular(16) : BorderRadius.zero, - child: BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: AppFrost.sigma, - sigmaY: AppFrost.sigma, - ), - backdropGroupKey: backdropKey, - child: DecoratedBox( - decoration: BoxDecoration(border: floating ? null : bottomBorder), - child: content, - ), - ), + return GlassSurface( + liquid: liquid, + borderRadius: floating + ? BorderRadius.circular(16) + : BorderRadius.zero, + frostTint: Colors.transparent, + border: floating ? null : bottomBorder, + backdropKey: backdropKey, + child: content, ); } diff --git a/lib/frontend/screens/profile/appearance_screen.dart b/lib/frontend/screens/profile/appearance_screen.dart index ef25849..7e904b5 100644 --- a/lib/frontend/screens/profile/appearance_screen.dart +++ b/lib/frontend/screens/profile/appearance_screen.dart @@ -19,6 +19,7 @@ import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/glossy_pill.dart'; +import '../../widgets/liquid_glass.dart'; class AppearanceScreen extends StatefulWidget { const AppearanceScreen({super.key}); @@ -134,6 +135,25 @@ class _AppearanceScreenState extends State { } } +void _applyVisualStyle(VisualStyle style) { + AppVisualStyle.save(style); + if (style == VisualStyle.liquidGlass) { + AppNavPillStyle.save(NavPillStyle.liquidGlass); + AppComposerBackground.save(ComposerBackground.liquidGlass); + AppChatChrome.save(ChatChromeStyle.liquidGlass); + return; + } + if (AppNavPillStyle.current.value == NavPillStyle.liquidGlass) { + AppNavPillStyle.save(NavPillStyle.frostBlur); + } + if (AppComposerBackground.current.value == ComposerBackground.liquidGlass) { + AppComposerBackground.save(ComposerBackground.frostBlur); + } + if (AppChatChrome.current.value == ChatChromeStyle.liquidGlass) { + AppChatChrome.save(ChatChromeStyle.transparent); + } +} + class _VisualStyleCard extends StatelessWidget { const _VisualStyleCard(); @@ -166,7 +186,12 @@ class _VisualStyleCard extends StatelessWidget { ValueListenableBuilder( valueListenable: AppVisualStyle.current, builder: (context, current, _) { + final selectable = + current == VisualStyle.liquidGlass && !LiquidGlass.isSupported + ? VisualStyle.glossy + : current; return SegmentedButton( + showSelectedIcon: false, segments: [ ButtonSegment( value: VisualStyle.materialYou, @@ -176,12 +201,17 @@ class _VisualStyleCard extends StatelessWidget { value: VisualStyle.glossy, label: Text(l10n.appearanceVisualStyleGlossy), ), + if (LiquidGlass.isSupported) + ButtonSegment( + value: VisualStyle.liquidGlass, + label: Text(l10n.appearanceVisualStyleLiquidGlass), + ), ], - selected: {current}, + selected: {selectable}, onSelectionChanged: (set) { if (set.isNotEmpty) { Haptics.selection(); - AppVisualStyle.save(set.first); + _applyVisualStyle(set.first); } }, ); @@ -225,32 +255,46 @@ class _ChatChromeCard extends StatelessWidget { ValueListenableBuilder( valueListenable: AppChatChrome.current, builder: (context, current, _) { - return SegmentedButton( - segments: [ - ButtonSegment( - value: ChatChromeStyle.color, - label: Text(l10n.appearanceChatChromeColor), - ), - ButtonSegment( - value: ChatChromeStyle.blur, - label: Text(l10n.appearanceChatChromeBlur), - ), - ButtonSegment( - value: ChatChromeStyle.none, - label: Text(l10n.appearanceChatChromeNone), - ), - ButtonSegment( - value: ChatChromeStyle.transparent, - label: Text(l10n.appearanceChatChromeTransparent), - ), - ], - selected: {current}, - onSelectionChanged: (set) { - if (set.isNotEmpty) { - Haptics.selection(); - AppChatChrome.save(set.first); - } - }, + final selectable = + current == ChatChromeStyle.liquidGlass && + !LiquidGlass.isSupported + ? ChatChromeStyle.transparent + : current; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton( + showSelectedIcon: false, + segments: [ + ButtonSegment( + value: ChatChromeStyle.color, + label: Text(l10n.appearanceChatChromeColor), + ), + ButtonSegment( + value: ChatChromeStyle.blur, + label: Text(l10n.appearanceChatChromeBlur), + ), + ButtonSegment( + value: ChatChromeStyle.none, + label: Text(l10n.appearanceChatChromeNone), + ), + ButtonSegment( + value: ChatChromeStyle.transparent, + label: Text(l10n.appearanceChatChromeTransparent), + ), + if (LiquidGlass.isSupported) + ButtonSegment( + value: ChatChromeStyle.liquidGlass, + label: Text(l10n.appearanceGlassMaterial), + ), + ], + selected: {selectable}, + onSelectionChanged: (set) { + if (set.isNotEmpty) { + Haptics.selection(); + AppChatChrome.save(set.first); + } + }, + ), ); }, ), @@ -317,7 +361,13 @@ class _ComposerBarCard extends StatelessWidget { ValueListenableBuilder( valueListenable: AppComposerBackground.current, builder: (context, current, _) { + final selectable = + current == ComposerBackground.liquidGlass && + !LiquidGlass.isSupported + ? ComposerBackground.frostBlur + : current; return SegmentedButton( + showSelectedIcon: false, segments: [ ButtonSegment( value: ComposerBackground.standard, @@ -327,8 +377,13 @@ class _ComposerBarCard extends StatelessWidget { value: ComposerBackground.frostBlur, label: Text(l10n.appearanceComposerBackgroundFrost), ), + if (LiquidGlass.isSupported) + ButtonSegment( + value: ComposerBackground.liquidGlass, + label: Text(l10n.appearanceGlassMaterial), + ), ], - selected: {current}, + selected: {selectable}, onSelectionChanged: (set) { if (set.isNotEmpty) { Haptics.selection(); @@ -376,7 +431,13 @@ class _NavPillStyleCard extends StatelessWidget { ValueListenableBuilder( valueListenable: AppNavPillStyle.current, builder: (context, current, _) { + final selectable = + current == NavPillStyle.liquidGlass && + !LiquidGlass.isSupported + ? NavPillStyle.frostBlur + : current; return SegmentedButton( + showSelectedIcon: false, segments: [ ButtonSegment( value: NavPillStyle.glossy, @@ -386,8 +447,13 @@ class _NavPillStyleCard extends StatelessWidget { value: NavPillStyle.frostBlur, label: Text(l10n.appearanceNavPillFrost), ), + if (LiquidGlass.isSupported) + ButtonSegment( + value: NavPillStyle.liquidGlass, + label: Text(l10n.appearanceGlassMaterial), + ), ], - selected: {current}, + selected: {selectable}, onSelectionChanged: (set) { if (set.isNotEmpty) { Haptics.selection(); diff --git a/lib/frontend/screens/stories/story_viewer_screen.dart b/lib/frontend/screens/stories/story_viewer_screen.dart index a011530..7c9e7a7 100644 --- a/lib/frontend/screens/stories/story_viewer_screen.dart +++ b/lib/frontend/screens/stories/story_viewer_screen.dart @@ -13,6 +13,7 @@ import '../../../main.dart' show storiesModule; import '../../../models/story.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/liquid_glass.dart'; import 'story_owner_info.dart'; const _quickReactions = ['❤️', '🔥', '😍', '👏', '😂', '😮']; @@ -627,33 +628,26 @@ class _StoryViewerScreenState extends State child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Center( - child: ClipRRect( + child: GlassSurface( borderRadius: BorderRadius.circular(30), - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: 14, sigmaY: 14), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(30), - border: Border.all( - color: Colors.white.withValues(alpha: 0.18), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final emoji in _quickReactions) - _ReactionButton( - emoji: emoji, - selected: current == emoji, - onTap: () => _toggleReaction(emoji), - ), - ], - ), + frostTint: Colors.white.withValues(alpha: 0.12), + frostSigma: 14, + border: Border.all(color: Colors.white.withValues(alpha: 0.18)), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final emoji in _quickReactions) + _ReactionButton( + emoji: emoji, + selected: current == emoji, + onTap: () => _toggleReaction(emoji), + ), + ], ), ), ), diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 6310d6e..21d7fc3 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -749,16 +749,21 @@ class _AttachmentSheetState extends State { valueListenable: AppNavPillStyle.current, builder: (context, navStyle, _) { final frost = - style == VisualStyle.glossy && - navStyle == NavPillStyle.frostBlur; + style.glossyChrome && + NavPillMaterial.isFrost(navStyle); + final liquid = + style.glossyChrome && + NavPillMaterial.isLiquid(navStyle); return SlidingPillNav( items: navItems, position: _currentPageT(), geometry: geometry, onTap: _onSectionTap, - backgroundColor: frost - ? AppFrost.navPillTint(cs) - : _composerColor(cs), + backgroundColor: liquid + ? null + : (frost + ? AppFrost.navPillTint(cs) + : _composerColor(cs)), borderColor: _composerBorderColor(cs), ); }, diff --git a/lib/frontend/widgets/glossy_pill.dart b/lib/frontend/widgets/glossy_pill.dart index 1d90a15..b3f5a8d 100644 --- a/lib/frontend/widgets/glossy_pill.dart +++ b/lib/frontend/widgets/glossy_pill.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../core/config/app_pill_gradient.dart'; import '../../core/config/app_visual_style.dart'; +import 'liquid_glass.dart'; class _GlossyParts { final bool dark; @@ -93,6 +94,7 @@ class GlossyPill extends StatelessWidget { final bool elevated; final BorderSide? borderSide; final double? blurSigma; + final bool liquid; final BackdropKey? backdropKey; const GlossyPill({ @@ -107,6 +109,7 @@ class GlossyPill extends StatelessWidget { this.elevated = false, this.borderSide, this.blurSigma, + this.liquid = false, this.backdropKey, }) : borderRadius = borderRadius ?? const BorderRadius.all(Radius.circular(100)); @@ -120,6 +123,7 @@ class GlossyPill extends StatelessWidget { valueListenable: AppVisualStyle.current, builder: (context, style, _) { if (style == VisualStyle.materialYou) return _flat(context); + if (liquid && LiquidGlass.isSupported) return _liquid(context); return ValueListenableBuilder( valueListenable: AppPillGradient.current, builder: (context, gradient, _) => _glossy(context, gradient), @@ -128,6 +132,36 @@ class GlossyPill extends StatelessWidget { ); } + Widget _liquid(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final base = color ?? cs.surfaceContainerHigh; + final content = Padding(padding: padding, child: child); + + return RepaintBoundary( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: GlossyDecor.rimBorder(base), + boxShadow: [GlossyDecor.dropShadow(base, depth)], + ), + child: LiquidGlassSurface( + borderRadius: borderRadius, + tint: Colors.transparent, + child: onTap == null && onLongPress == null + ? content + : Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: content, + ), + ), + ), + ), + ); + } + Widget _flat(BuildContext context) { final cs = Theme.of(context).colorScheme; final base = color ?? cs.surfaceContainerHigh; diff --git a/lib/frontend/widgets/liquid_glass.dart b/lib/frontend/widgets/liquid_glass.dart new file mode 100644 index 0000000..a2da565 --- /dev/null +++ b/lib/frontend/widgets/liquid_glass.dart @@ -0,0 +1,408 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../core/config/app_frost.dart'; +import '../../core/config/app_liquid_glass.dart'; +import '../../core/config/app_visual_style.dart'; + +class LiquidGlass { + static const String _asset = 'shaders/liquid_glass.frag'; + + static ui.FragmentProgram? _program; + static bool _loadAttempted = false; + + static bool get isSupported => _program != null; + + static bool get active => + isSupported && AppVisualStyle.current.value == VisualStyle.liquidGlass; + + static Future load() async { + if (_loadAttempted) return; + _loadAttempted = true; + if (!AppLiquidGlass.enabled) return; + if (!ui.ImageFilter.isShaderFilterSupported) return; + try { + _program = await ui.FragmentProgram.fromAsset(_asset); + } catch (_) { + _program = null; + } + } +} + +class GlassSurface extends StatelessWidget { + final bool liquid; + final BorderRadius borderRadius; + final Color frostTint; + final double frostSigma; + final Color liquidTint; + final BoxBorder? border; + final BackdropKey? backdropKey; + final Widget child; + + const GlassSurface({ + super.key, + this.liquid = false, + this.borderRadius = BorderRadius.zero, + required this.frostTint, + this.frostSigma = AppFrost.sigma, + this.liquidTint = Colors.transparent, + this.border, + this.backdropKey, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final glass = liquid && LiquidGlass.isSupported; + final decorated = DecoratedBox( + decoration: BoxDecoration( + color: glass ? null : frostTint, + border: border, + ), + child: child, + ); + if (glass) { + return LiquidGlassSurface( + borderRadius: borderRadius, + tint: liquidTint, + child: decorated, + ); + } + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: frostSigma, sigmaY: frostSigma), + backdropGroupKey: backdropKey, + child: decorated, + ), + ); + } +} + +class LiquidGlassSurface extends StatelessWidget { + final BorderRadius borderRadius; + final Color tint; + final double blurSigma; + final double spread; + final double refraction; + final double chroma; + final double specular; + final Offset light; + final double tintFeather; + final double rimWidth; + final Widget child; + + const LiquidGlassSurface({ + super.key, + required this.borderRadius, + required this.tint, + this.blurSigma = AppLiquidGlass.blurSigma, + this.spread = AppLiquidGlass.spread, + this.refraction = AppLiquidGlass.refraction, + this.chroma = AppLiquidGlass.chroma, + this.specular = AppLiquidGlass.specular, + this.light = AppLiquidGlass.light, + this.tintFeather = AppLiquidGlass.tintFeather, + this.rimWidth = AppLiquidGlass.rimWidth, + this.child = const SizedBox.expand(), + }); + + @override + Widget build(BuildContext context) { + if (!LiquidGlass.isSupported) return child; + return _LiquidGlassBackdrop( + borderRadius: borderRadius, + tint: tint, + blurSigma: blurSigma, + spread: spread, + refraction: refraction, + chroma: chroma, + specular: specular, + light: light, + tintFeather: tintFeather, + rimWidth: rimWidth, + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + child: child, + ); + } +} + +class _LiquidGlassBackdrop extends SingleChildRenderObjectWidget { + final BorderRadius borderRadius; + final Color tint; + final double blurSigma; + final double spread; + final double refraction; + final double chroma; + final double specular; + final Offset light; + final double tintFeather; + final double rimWidth; + final double devicePixelRatio; + + const _LiquidGlassBackdrop({ + required this.borderRadius, + required this.tint, + required this.blurSigma, + required this.spread, + required this.refraction, + required this.chroma, + required this.specular, + required this.light, + required this.tintFeather, + required this.rimWidth, + required this.devicePixelRatio, + required super.child, + }); + + @override + _RenderLiquidGlass createRenderObject(BuildContext context) { + return _RenderLiquidGlass( + borderRadius: borderRadius, + tint: tint, + blurSigma: blurSigma, + spread: spread, + refraction: refraction, + chroma: chroma, + specular: specular, + light: light, + tintFeather: tintFeather, + rimWidth: rimWidth, + devicePixelRatio: devicePixelRatio, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderLiquidGlass renderObject, + ) { + renderObject + ..borderRadius = borderRadius + ..tint = tint + ..blurSigma = blurSigma + ..spread = spread + ..refraction = refraction + ..chroma = chroma + ..specular = specular + ..light = light + ..tintFeather = tintFeather + ..rimWidth = rimWidth + ..devicePixelRatio = devicePixelRatio; + } +} + +class _RenderLiquidGlass extends RenderProxyBox { + _RenderLiquidGlass({ + required BorderRadius borderRadius, + required Color tint, + required double blurSigma, + required double spread, + required double refraction, + required double chroma, + required double specular, + required Offset light, + required double tintFeather, + required double rimWidth, + required double devicePixelRatio, + }) : _borderRadius = borderRadius, + _tint = tint, + _blurSigma = blurSigma, + _spread = spread, + _refraction = refraction, + _chroma = chroma, + _specular = specular, + _light = light, + _tintFeather = tintFeather, + _rimWidth = rimWidth, + _devicePixelRatio = devicePixelRatio; + + final LayerHandle _blurClipHandle = + LayerHandle(); + final LayerHandle _blurHandle = + LayerHandle(); + final LayerHandle _clipHandle = LayerHandle(); + final LayerHandle _backdropHandle = + LayerHandle(); + + ui.FragmentShader? _shader; + + BorderRadius _borderRadius; + set borderRadius(BorderRadius value) { + if (_borderRadius == value) return; + _borderRadius = value; + markNeedsPaint(); + } + + Color _tint; + set tint(Color value) { + if (_tint == value) return; + _tint = value; + markNeedsPaint(); + } + + double _blurSigma; + set blurSigma(double value) { + if (_blurSigma == value) return; + _blurSigma = value; + markNeedsPaint(); + } + + double _spread; + set spread(double value) { + if (_spread == value) return; + _spread = value; + markNeedsPaint(); + } + + double _refraction; + set refraction(double value) { + if (_refraction == value) return; + _refraction = value; + markNeedsPaint(); + } + + double _chroma; + set chroma(double value) { + if (_chroma == value) return; + _chroma = value; + markNeedsPaint(); + } + + double _specular; + set specular(double value) { + if (_specular == value) return; + _specular = value; + markNeedsPaint(); + } + + Offset _light; + set light(Offset value) { + if (_light == value) return; + _light = value; + markNeedsPaint(); + } + + double _tintFeather; + set tintFeather(double value) { + if (_tintFeather == value) return; + _tintFeather = value; + markNeedsPaint(); + } + + double _rimWidth; + set rimWidth(double value) { + if (_rimWidth == value) return; + _rimWidth = value; + markNeedsPaint(); + } + + double _devicePixelRatio; + set devicePixelRatio(double value) { + if (_devicePixelRatio == value) return; + _devicePixelRatio = value; + markNeedsPaint(); + } + + @override + bool get alwaysNeedsCompositing => true; + + @override + void dispose() { + _blurClipHandle.layer = null; + _blurHandle.layer = null; + _clipHandle.layer = null; + _backdropHandle.layer = null; + _shader?.dispose(); + _shader = null; + super.dispose(); + } + + ui.ImageFilter? _buildFilter() { + final program = LiquidGlass._program; + if (program == null) return null; + + final shader = _shader ??= program.fragmentShader(); + final dpr = _devicePixelRatio; + final topLeft = localToGlobal(Offset.zero); + final left = (topLeft.dx * dpr).roundToDouble(); + final top = (topLeft.dy * dpr).roundToDouble(); + final width = (size.width * dpr).roundToDouble(); + final height = (size.height * dpr).roundToDouble(); + final radius = _borderRadius.topLeft.x * dpr; + + shader + ..setFloat(2, left) + ..setFloat(3, top) + ..setFloat(4, width) + ..setFloat(5, height) + ..setFloat(6, radius) + ..setFloat(7, _spread) + ..setFloat(8, _refraction * dpr) + ..setFloat(9, _chroma) + ..setFloat(10, _specular) + ..setFloat(11, _tint.r) + ..setFloat(12, _tint.g) + ..setFloat(13, _tint.b) + ..setFloat(14, _tint.a) + ..setFloat(15, _light.dx) + ..setFloat(16, _light.dy) + ..setFloat(17, _tintFeather * dpr) + ..setFloat(18, _rimWidth * dpr); + + return ui.ImageFilter.shader(shader); + } + + @override + void paint(PaintingContext context, Offset offset) { + final filter = size.isEmpty ? null : _buildFilter(); + if (filter == null) { + _blurClipHandle.layer = null; + _blurHandle.layer = null; + _clipHandle.layer = null; + _backdropHandle.layer = null; + super.paint(context, offset); + return; + } + + final bounds = Offset.zero & size; + final shape = _borderRadius.toRRect(bounds); + + if (_blurSigma > 0) { + _blurClipHandle.layer = context.pushClipRRect( + needsCompositing, + offset, + bounds, + shape, + (PaintingContext innerContext, Offset innerOffset) { + final blur = _blurHandle.layer ??= BackdropFilterLayer(); + blur.filter = ui.ImageFilter.blur( + sigmaX: _blurSigma, + sigmaY: _blurSigma, + tileMode: TileMode.mirror, + ); + innerContext.pushLayer(blur, (_, _) {}, innerOffset); + }, + oldLayer: _blurClipHandle.layer, + ); + } else { + _blurClipHandle.layer = null; + _blurHandle.layer = null; + } + + _clipHandle.layer = context.pushClipRRect( + needsCompositing, + offset, + bounds, + shape, + (PaintingContext innerContext, Offset innerOffset) { + final backdrop = _backdropHandle.layer ??= BackdropFilterLayer(); + backdrop.filter = filter; + innerContext.pushLayer(backdrop, super.paint, innerOffset); + }, + oldLayer: _clipHandle.layer, + ); + } +} diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart index 24a758c..5fa3260 100644 --- a/lib/frontend/widgets/photo_viewer.dart +++ b/lib/frontend/widgets/photo_viewer.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; @@ -13,6 +12,7 @@ import '../../backend/modules/messages.dart'; import '../../backend/modules/shared_content.dart'; import '../../core/cache/info_cache.dart'; import '../../core/config/app_frost.dart'; +import 'liquid_glass.dart'; import '../../core/utils/format.dart'; import '../../core/utils/media_cache.dart'; import '../../core/utils/media_saver.dart'; @@ -578,32 +578,25 @@ class _PhotoViewerScreenState extends State { } Widget _buildCaption(String caption) { - return ClipRRect( + return GlassSurface( borderRadius: BorderRadius.circular(12), - child: BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: AppFrost.panelSigma, - sigmaY: AppFrost.panelSigma, - ), - child: Container( - constraints: const BoxConstraints(maxHeight: 120), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.28), - border: Border.all( - color: Colors.white.withValues(alpha: 0.12), - width: 0.5, - ), - borderRadius: BorderRadius.circular(12), - ), - child: SingleChildScrollView( - child: Text( - caption, - style: const TextStyle( - color: Colors.white, - fontSize: 15, - height: 1.3, - ), + frostTint: Colors.black.withValues(alpha: 0.28), + frostSigma: AppFrost.panelSigma, + liquidTint: Colors.black.withValues(alpha: 0.28), + border: Border.all( + color: Colors.white.withValues(alpha: 0.12), + width: 0.5, + ), + child: Container( + constraints: const BoxConstraints(maxHeight: 120), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: SingleChildScrollView( + child: Text( + caption, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + height: 1.3, ), ), ), diff --git a/lib/frontend/widgets/sliding_pill_nav.dart b/lib/frontend/widgets/sliding_pill_nav.dart index a37a8a8..1f9e768 100644 --- a/lib/frontend/widgets/sliding_pill_nav.dart +++ b/lib/frontend/widgets/sliding_pill_nav.dart @@ -3,11 +3,13 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import '../../core/config/app_frost.dart'; +import '../../core/config/app_liquid_glass.dart'; import '../../core/config/app_nav_pill_style.dart'; import '../../core/config/app_pill_gradient.dart'; import '../../core/config/app_visual_style.dart'; import 'animated_lottie_icon.dart'; import 'glossy_pill.dart'; +import 'liquid_glass.dart'; class PillNavItem { final IconData icon; @@ -90,12 +92,13 @@ class SlidingPillNav extends StatelessWidget { return ValueListenableBuilder( valueListenable: AppVisualStyle.current, builder: (context, style, _) { - if (style != VisualStyle.glossy) { + if (style == VisualStyle.materialYou) { return _buildNav( context, glossy: false, gradient: false, frost: false, + liquid: false, ); } return ValueListenableBuilder( @@ -107,7 +110,8 @@ class SlidingPillNav extends StatelessWidget { context, glossy: true, gradient: gradient, - frost: navStyle == NavPillStyle.frostBlur, + frost: NavPillMaterial.isFrost(navStyle), + liquid: NavPillMaterial.isLiquid(navStyle), ), ), ); @@ -120,20 +124,23 @@ class SlidingPillNav extends StatelessWidget { required bool glossy, required bool gradient, required bool frost, + required bool liquid, }) { final cs = Theme.of(context).colorScheme; final visualSel = position.round().clamp(0, items.length - 1); - final base = - backgroundColor ?? - (frost ? AppFrost.navPillTint(cs) : cs.surfaceContainerHigh); - final useGradient = glossy && gradient; - final frosted = frost && base.a < 1; + final translucent = backgroundColor != null && backgroundColor!.a < 1; + final base = liquid + ? (translucent ? backgroundColor! : AppLiquidGlass.navTint(cs)) + : (backgroundColor ?? + (frost ? AppFrost.navPillTint(cs) : cs.surfaceContainerHigh)); + final useGradient = glossy && gradient && !liquid; + final frosted = frost && !liquid && base.a < 1; return Container( height: height, padding: const EdgeInsets.symmetric(horizontal: 2), decoration: BoxDecoration( - color: useGradient ? null : base, + color: useGradient || liquid ? null : base, gradient: useGradient ? GlossyDecor.fillGradient(base) : null, borderRadius: BorderRadius.circular(34), border: glossy @@ -145,8 +152,8 @@ class SlidingPillNav extends StatelessWidget { ? null : [ BoxShadow( - color: Colors.black.withValues(alpha: 0.5), - blurRadius: 20, + color: Colors.black.withValues(alpha: liquid ? 0.28 : 0.5), + blurRadius: liquid ? 26 : 20, offset: const Offset(0, 10), ), ], @@ -154,6 +161,15 @@ class SlidingPillNav extends StatelessWidget { child: Stack( clipBehavior: Clip.hardEdge, children: [ + if (liquid) + Positioned.fill( + child: IgnorePointer( + child: LiquidGlassSurface( + borderRadius: BorderRadius.circular(34), + tint: base, + ), + ), + ), if (frosted) Positioned.fill( child: IgnorePointer( diff --git a/lib/frontend/widgets/sticker_panel.dart b/lib/frontend/widgets/sticker_panel.dart index 0fedcdb..995bb78 100644 --- a/lib/frontend/widgets/sticker_panel.dart +++ b/lib/frontend/widgets/sticker_panel.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/gestures.dart'; @@ -17,6 +16,8 @@ import 'segmented_pill_toggle.dart'; import 'small_spinner.dart'; import 'lottie_image.dart'; import 'sticker_peek.dart'; +import '../../core/config/app_frost.dart'; +import 'liquid_glass.dart'; class _DragScrollBehavior extends MaterialScrollBehavior { const _DragScrollBehavior(); @@ -248,30 +249,19 @@ class _StickerPanelState extends State final cs = Theme.of(context).colorScheme; return SizedBox( height: widget.height, - child: ClipRect( - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: 34, sigmaY: 34), - child: DecoratedBox( - decoration: BoxDecoration( - color: cs.surface.withValues(alpha: 0.38), - border: Border( - top: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), - width: 0.5, - ), - ), + child: GlassSurface( + liquid: false, + frostTint: AppFrost.panelTint(cs), + border: Border(top: AppFrost.hairline(cs)), + child: Column( + children: [ + Expanded( + child: _mode == _modeEmoji && widget.onEmojiTap != null + ? EmojiPanel(onEmojiTap: widget.onEmojiTap!) + : _buildStickerBody(cs), ), - child: Column( - children: [ - Expanded( - child: _mode == _modeEmoji && widget.onEmojiTap != null - ? EmojiPanel(onEmojiTap: widget.onEmojiTap!) - : _buildStickerBody(cs), - ), - if (widget.onEmojiTap != null) _buildToggleBar(cs), - ], - ), - ), + if (widget.onEmojiTap != null) _buildToggleBar(cs), + ], ), ), ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fb1f075..b4d6bb0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -290,6 +290,8 @@ "appearanceVisualStyleSubtitle": "Material You or dimensional Glossy capsules", "appearanceVisualStyleMaterialYou": "Material You", "appearanceVisualStyleGlossy": "Glossy", + "appearanceVisualStyleLiquidGlass": "Liquid Glass", + "appearanceGlassMaterial": "Glass", "appearanceChatChromeTitle": "Chat screen elements", "appearanceChatChromeSubtitle": "Background of the top and bottom panels: color, blur, or transparent. With blur or transparency, messages scroll under the panels", "appearanceChatChromeColor": "Color", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3611761..2956c43 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1526,6 +1526,18 @@ abstract class AppLocalizations { /// **'Glossy'** String get appearanceVisualStyleGlossy; + /// No description provided for @appearanceVisualStyleLiquidGlass. + /// + /// In en, this message translates to: + /// **'Liquid Glass'** + String get appearanceVisualStyleLiquidGlass; + + /// No description provided for @appearanceGlassMaterial. + /// + /// In en, this message translates to: + /// **'Glass'** + String get appearanceGlassMaterial; + /// No description provided for @appearanceChatChromeTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4f60055..36a35aa 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -758,6 +758,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appearanceVisualStyleGlossy => 'Glossy'; + @override + String get appearanceVisualStyleLiquidGlass => 'Liquid Glass'; + + @override + String get appearanceGlassMaterial => 'Glass'; + @override String get appearanceChatChromeTitle => 'Chat screen elements'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 83ce576..98f6636 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -761,6 +761,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appearanceVisualStyleGlossy => 'Glossy'; + @override + String get appearanceVisualStyleLiquidGlass => 'Liquid Glass'; + + @override + String get appearanceGlassMaterial => 'Стекло'; + @override String get appearanceChatChromeTitle => 'Элементы экрана чата'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c8bf08a..2548d5c 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -255,6 +255,8 @@ "appearanceVisualStyleSubtitle": "Material You или объёмные Glossy-капсулы", "appearanceVisualStyleMaterialYou": "Material You", "appearanceVisualStyleGlossy": "Glossy", + "appearanceVisualStyleLiquidGlass": "Liquid Glass", + "appearanceGlassMaterial": "Стекло", "appearanceChatChromeTitle": "Элементы экрана чата", "appearanceChatChromeSubtitle": "Фон панелей сверху и снизу: цвет, размытие или прозрачно. При размытии и прозрачности сообщения заходят под панели", "appearanceChatChromeColor": "Цвет", diff --git a/lib/main.dart b/lib/main.dart index 14bda70..d6ad510 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -79,6 +79,7 @@ import 'frontend/debug/fps_overlay_layer.dart'; import 'frontend/screens/auth/login_screen.dart'; import 'frontend/widgets/adaptive_shell.dart'; import 'frontend/widgets/custom_notification.dart'; +import 'frontend/widgets/liquid_glass.dart'; import 'frontend/widgets/theme_reveal.dart'; final api = Api(); @@ -194,6 +195,7 @@ void main(List args) async { final amoledFuture = AppAmoled.load(); final pillGradientFuture = AppPillGradient.load(); final visualStyleFuture = AppVisualStyle.load(); + final liquidGlassFuture = LiquidGlass.load(); final chatChromeFuture = AppChatChrome.load(); final composerStyleFuture = AppComposerStyle.load(); final composerBackgroundFuture = AppComposerBackground.load(); @@ -248,6 +250,7 @@ void main(List args) async { amoledFuture, pillGradientFuture, visualStyleFuture, + liquidGlassFuture, chatChromeFuture, composerStyleFuture, composerBackgroundFuture, diff --git a/pubspec.yaml b/pubspec.yaml index 30eb5d0..5b39840 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -125,6 +125,9 @@ flutter_launcher_icons: flutter: generate: true + shaders: + - shaders/liquid_glass.frag + # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. diff --git a/shaders/liquid_glass.frag b/shaders/liquid_glass.frag new file mode 100644 index 0000000..55da549 --- /dev/null +++ b/shaders/liquid_glass.frag @@ -0,0 +1,104 @@ +#version 460 core + +#include + +precision highp float; + +uniform vec2 uSize; +uniform vec2 uRectOrigin; +uniform vec2 uRectSize; +uniform float uRadius; +uniform float uSpread; +uniform float uRefraction; +uniform float uChroma; +uniform float uSpecular; +uniform vec4 uTint; +uniform vec2 uLight; +uniform float uTintFeather; +uniform float uRimWidth; + +uniform sampler2D uBackdrop; + +out vec4 fragColor; + +float roundedBoxSdf(vec2 p, vec2 halfSize, float radius) { + vec2 q = abs(p) - halfSize + radius; + return min(max(q.x, q.y), 0.0) + length(max(q, vec2(0.0))) - radius; +} + +vec2 surfaceNormal(vec2 p, vec2 halfSize, float radius) { + vec2 unit = vec2(1.0, 0.0); + float dx = roundedBoxSdf(p + unit.xy, halfSize, radius) - + roundedBoxSdf(p - unit.xy, halfSize, radius); + float dy = roundedBoxSdf(p + unit.yx, halfSize, radius) - + roundedBoxSdf(p - unit.yx, halfSize, radius); + return normalize(vec2(dx, dy) + vec2(1e-6)); +} + +const int TAPS = 5; + +float displacement(float distance, float reach) { + float bevel = 1.0 - clamp(-distance / reach, 0.0, 1.0); + return uRefraction * bevel * bevel * (1.0 + bevel); +} + +vec3 sampleBackdrop(vec2 coord) { + vec2 uv = coord / uSize; +#ifdef IMPELLER_TARGET_OPENGLES + uv.y = 1.0 - uv.y; +#endif + return texture(uBackdrop, clamp(uv, vec2(0.0), vec2(1.0))).rgb; +} + +void main() { + vec2 fragCoord = FlutterFragCoord().xy; + vec2 halfSize = uRectSize * 0.5; + vec2 center = uRectOrigin + halfSize; + vec2 p = fragCoord - center; + + float radius = min(uRadius, min(halfSize.x, halfSize.y)); + float sd = roundedBoxSdf(p, halfSize, radius); + + if (sd > 0.0) { + fragColor = vec4(sampleBackdrop(fragCoord), 1.0); + return; + } + + vec2 normal = surfaceNormal(p, halfSize, radius); + float reach = max(uSpread * min(halfSize.x, halfSize.y), 1.0); + float shift = displacement(sd, reach); + float lens = shift / max(uRefraction, 1e-6); + + float slope = displacement(sd + 1.0, reach) - displacement(sd - 1.0, reach); + float footprint = clamp(abs(1.0 + slope * 0.5), 1.0, 24.0); + float aberration = uChroma * lens; + + vec3 refracted = vec3(0.0); + for (int i = 0; i < TAPS; i++) { + float offset = (float(i) / float(TAPS - 1) - 0.5) * footprint; + vec2 base = fragCoord + normal * (shift + offset); + if (aberration > 0.0) { + refracted.r += sampleBackdrop(base + normal * shift * aberration).r; + refracted.g += sampleBackdrop(base).g; + refracted.b += sampleBackdrop(base - normal * shift * aberration).b; + } else { + refracted += sampleBackdrop(base); + } + } + refracted /= float(TAPS); + + float veil = smoothstep(0.0, 1.0, clamp(-sd / max(uTintFeather, 1.0), 0.0, 1.0)); + vec3 color = mix(refracted, uTint.rgb, uTint.a * veil); + + vec2 light = normalize(uLight + vec2(1e-6)); + float facing = dot(normal, light); + float rim = 1.0 - clamp(-sd / max(uRimWidth, 1.0), 0.0, 1.0); + rim = rim * rim; + float highlight = pow(max(facing, 0.0), 5.0) * rim * uSpecular; + float shade = pow(max(-facing, 0.0), 4.0) * rim * uSpecular * 0.35; + + color += vec3(highlight); + color = mix(color, color * 0.72, shade); + + fragColor = vec4(clamp(color, vec3(0.0), vec3(1.0)), 1.0); +}