diff --git a/lib/core/config/app_frost.dart b/lib/core/config/app_frost.dart index 3d8b2eb..aca7c83 100644 --- a/lib/core/config/app_frost.dart +++ b/lib/core/config/app_frost.dart @@ -12,6 +12,11 @@ class AppFrost { static Color pillTint(ColorScheme cs) => cs.surfaceContainerHigh.withValues(alpha: 0.45); + static Color navPillTint(ColorScheme cs) => + cs.surfaceContainerHigh.withValues(alpha: 0.28); + + static Color fabTint(ColorScheme cs) => navPillTint(cs); + static Color inputTint(ColorScheme cs) => cs.surfaceContainerHighest.withValues(alpha: 0.45); diff --git a/lib/core/config/app_nav_pill_style.dart b/lib/core/config/app_nav_pill_style.dart new file mode 100644 index 0000000..9adfb3d --- /dev/null +++ b/lib/core/config/app_nav_pill_style.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +enum NavPillStyle { glossy, frostBlur } + +class AppNavPillStyle { + static const prefKey = 'app_nav_pill_style'; + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: NavPillStyle.glossy, + encode: (value) => value.name, + decode: _parse, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(NavPillStyle value) => _setting.save(value); + + static NavPillStyle _parse(String? val) => + enumFromName(NavPillStyle.values, val, NavPillStyle.glossy); +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index db7a196..d9b34f7 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -34,6 +34,9 @@ import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_animations.dart'; +import '../../../core/config/app_frost.dart'; +import '../../../core/config/app_nav_pill_style.dart'; +import '../../../core/config/app_visual_style.dart'; import '../../../core/config/app_stories.dart'; import '../../../core/config/app_colors.dart'; import '../../../core/config/komet_settings.dart'; @@ -186,6 +189,7 @@ class _ChatListScreenState extends State late AnimationController _navPageAnimController; late AnimationController _fabController; + final BackdropKey _frostBackdrop = BackdropKey(); late PageController _folderPageController; late AnimationController _storiesRevealController; @@ -657,7 +661,9 @@ class _ChatListScreenState extends State final me = _profile?.id; final self = _selfOwnerInfo(); if (me == null || self == null) return const {}; - return {me: StoryOwnerInfo(name: 'Ваша история', avatarUrl: self.avatarUrl)}; + return { + me: StoryOwnerInfo(name: 'Ваша история', avatarUrl: self.avatarUrl), + }; } void _openStories(int index, [Offset? origin]) { @@ -757,9 +763,9 @@ class _ChatListScreenState extends State } var folders = await FoldersModule.loadFolders(p.id); final foldersKnown = await FoldersModule.hasReceivedFoldersList(p.id); - final contactIds = (await ContactsModule.getContacts(p.id)) - .map((c) => c.id) - .toSet(); + final contactIds = (await ContactsModule.getContacts( + p.id, + )).map((c) => c.id).toSet(); final allChatsFolder = ChatFolder( id: 'all.chat.folder', @@ -1859,6 +1865,7 @@ class _ChatListScreenState extends State geometry: geometry, iconSize: 20, labelGap: 4, + backdropKey: _frostBackdrop, onTap: _onNavTabSelected, onItemLongPress: (index, pos) { if (index == 3) _openAccountSwitcher(pos); @@ -2020,12 +2027,32 @@ class _ChatListScreenState extends State Positioned( right: 20, bottom: bottomInset + 90, - child: GlossyPill( - onTap: _toggleFab, - color: cs.primaryContainer, - borderRadius: BorderRadius.circular(28), - elevated: true, - depth: 12, + child: ValueListenableBuilder( + valueListenable: AppVisualStyle.current, + builder: (context, style, child) => + ValueListenableBuilder( + valueListenable: AppNavPillStyle.current, + builder: (context, navStyle, child) { + final frost = + style == VisualStyle.glossy && + navStyle == NavPillStyle.frostBlur; + return GlossyPill( + onTap: _toggleFab, + color: frost + ? AppFrost.fabTint(cs) + : cs.primaryContainer, + blurSigma: frost + ? AppFrost.sigma + : null, + backdropKey: _frostBackdrop, + borderRadius: BorderRadius.circular(28), + elevated: true, + depth: 12, + child: child!, + ); + }, + child: child, + ), child: SizedBox( width: 56, height: 56, @@ -2119,10 +2146,7 @@ class _ChatListScreenState extends State return InkWell( onTap: () { if (_isSelectionMode) return; - pushSwipeable( - context, - (_) => const ChatListScreen(archiveMode: true), - ); + pushSwipeable(context, (_) => const ChatListScreen(archiveMode: true)); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), @@ -2151,10 +2175,7 @@ class _ChatListScreenState extends State if (_archivedUnread > 0) Container( margin: const EdgeInsets.only(right: 8), - padding: const EdgeInsets.symmetric( - horizontal: 7, - vertical: 2, - ), + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), decoration: BoxDecoration( color: cs.primary, borderRadius: BorderRadius.circular(12), @@ -2310,7 +2331,9 @@ class _ChatListScreenState extends State picked.item.localFile ?? await picked.item.originFile(); if (file == null) { - if (mounted) showCustomNotification(context, 'Не удалось открыть фото'); + if (mounted) { + showCustomNotification(context, 'Не удалось открыть фото'); + } return; } if (!mounted) return; @@ -2915,7 +2938,6 @@ class _ChatListScreenState extends State ), ); } - } class _StoriesUi extends ChangeNotifier { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 370cf4b..d566881 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -498,6 +498,7 @@ class _ChatScreenState extends State bool _scrollDownVisible = false; int _listEpoch = 0; final List<({String id, double pixels, double alignment})> _returnStack = []; + bool _returningToAnchor = false; final Map _separatorKeys = {}; String? _lastSentId; final ValueNotifier _otherUnread = ValueNotifier(0); @@ -3654,6 +3655,7 @@ class _ChatScreenState extends State } void _onScrollDownTap() { + if (_returningToAnchor || _navigatingToTarget) return; if (!_scrollController.hasClients) { _scrollToBottom(); return; @@ -3662,7 +3664,12 @@ class _ChatScreenState extends State while (_returnStack.isNotEmpty) { final anchor = _returnStack.removeLast(); if (anchor.pixels < pixels && _messages.any((m) => m.id == anchor.id)) { - unawaited(_returnToAnchor(anchor)); + _returningToAnchor = true; + unawaited( + _returnToAnchor( + anchor, + ).whenComplete(() => _returningToAnchor = false), + ); return; } } diff --git a/lib/frontend/screens/profile/appearance_screen.dart b/lib/frontend/screens/profile/appearance_screen.dart index 8f78c1d..ef25849 100644 --- a/lib/frontend/screens/profile/appearance_screen.dart +++ b/lib/frontend/screens/profile/appearance_screen.dart @@ -12,6 +12,7 @@ import '../../../core/config/app_visual_style.dart'; import '../../../core/config/app_chat_chrome.dart'; import '../../../core/config/app_composer_background.dart'; import '../../../core/config/app_composer_style.dart'; +import '../../../core/config/app_nav_pill_style.dart'; import '../../../core/utils/bubble_radius.dart'; import '../../../core/utils/debouncer.dart'; import '../../../core/utils/haptics.dart'; @@ -123,6 +124,8 @@ class _AppearanceScreenState extends State { const SizedBox(height: 12), const _ComposerBarCard(), const SizedBox(height: 12), + const _NavPillStyleCard(), + const SizedBox(height: 12), const _GradientToggleCard(), ], ), @@ -341,6 +344,65 @@ class _ComposerBarCard extends StatelessWidget { } } +class _NavPillStyleCard extends StatelessWidget { + const _NavPillStyleCard(); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(28), + padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), + depth: 6, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.appearanceNavPillTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + l10n.appearanceNavPillSubtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: AppNavPillStyle.current, + builder: (context, current, _) { + return SegmentedButton( + segments: [ + ButtonSegment( + value: NavPillStyle.glossy, + label: Text(l10n.appearanceNavPillGlossy), + ), + ButtonSegment( + value: NavPillStyle.frostBlur, + label: Text(l10n.appearanceNavPillFrost), + ), + ], + selected: {current}, + onSelectionChanged: (set) { + if (set.isNotEmpty) { + Haptics.selection(); + AppNavPillStyle.save(set.first); + } + }, + ); + }, + ), + ], + ), + ); + } +} + class _GradientToggleCard extends StatelessWidget { const _GradientToggleCard(); @@ -590,7 +652,12 @@ class _ColorPickerCard extends StatelessWidget { ); } - Widget _buildBody(ColorScheme cs, AppLocalizations l10n, Color col, bool sys) { + Widget _buildBody( + ColorScheme cs, + AppLocalizations l10n, + Color col, + bool sys, + ) { final swatchColor = sys ? cs.primary : col; return GlossyPill( diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 9142cfe..6310d6e 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -4,6 +4,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:komet/core/config/app_frost.dart'; +import 'package:komet/core/config/app_nav_pill_style.dart'; +import 'package:komet/core/config/app_visual_style.dart'; import 'package:komet/core/media/gallery_source.dart'; import 'package:komet/core/utils/format.dart'; import 'package:komet/frontend/widgets/attachment/media_preview_screen.dart'; @@ -739,13 +742,27 @@ class _AttachmentSheetState extends State { animation: _pageController, builder: (context, _) { final cs = Theme.of(context).colorScheme; - return SlidingPillNav( - items: navItems, - position: _currentPageT(), - geometry: geometry, - onTap: _onSectionTap, - backgroundColor: _composerColor(cs), - borderColor: _composerBorderColor(cs), + return ValueListenableBuilder( + valueListenable: AppVisualStyle.current, + builder: (context, style, _) => + ValueListenableBuilder( + valueListenable: AppNavPillStyle.current, + builder: (context, navStyle, _) { + final frost = + style == VisualStyle.glossy && + navStyle == NavPillStyle.frostBlur; + return SlidingPillNav( + items: navItems, + position: _currentPageT(), + geometry: geometry, + onTap: _onSectionTap, + backgroundColor: frost + ? AppFrost.navPillTint(cs) + : _composerColor(cs), + borderColor: _composerBorderColor(cs), + ); + }, + ), ); }, ), diff --git a/lib/frontend/widgets/sliding_pill_nav.dart b/lib/frontend/widgets/sliding_pill_nav.dart index c877d32..a37a8a8 100644 --- a/lib/frontend/widgets/sliding_pill_nav.dart +++ b/lib/frontend/widgets/sliding_pill_nav.dart @@ -1,5 +1,9 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; +import '../../core/config/app_frost.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'; @@ -50,6 +54,7 @@ class SlidingPillNav extends StatelessWidget { final Color? backgroundColor; final Color? borderColor; final bool iconsOnly; + final BackdropKey? backdropKey; const SlidingPillNav({ super.key, @@ -64,6 +69,7 @@ class SlidingPillNav extends StatelessWidget { this.backgroundColor, this.borderColor, this.iconsOnly = false, + this.backdropKey, }); static const double height = 68; @@ -85,12 +91,25 @@ class SlidingPillNav extends StatelessWidget { valueListenable: AppVisualStyle.current, builder: (context, style, _) { if (style != VisualStyle.glossy) { - return _buildNav(context, glossy: false, gradient: false); + return _buildNav( + context, + glossy: false, + gradient: false, + frost: false, + ); } return ValueListenableBuilder( valueListenable: AppPillGradient.current, builder: (context, gradient, _) => - _buildNav(context, glossy: true, gradient: gradient), + ValueListenableBuilder( + valueListenable: AppNavPillStyle.current, + builder: (context, navStyle, _) => _buildNav( + context, + glossy: true, + gradient: gradient, + frost: navStyle == NavPillStyle.frostBlur, + ), + ), ); }, ); @@ -100,11 +119,15 @@ class SlidingPillNav extends StatelessWidget { BuildContext context, { required bool glossy, required bool gradient, + required bool frost, }) { final cs = Theme.of(context).colorScheme; final visualSel = position.round().clamp(0, items.length - 1); - final base = backgroundColor ?? cs.surfaceContainerHigh; + final base = + backgroundColor ?? + (frost ? AppFrost.navPillTint(cs) : cs.surfaceContainerHigh); final useGradient = glossy && gradient; + final frosted = frost && base.a < 1; return Container( height: height, @@ -118,17 +141,35 @@ class SlidingPillNav extends StatelessWidget { : (borderColor != null ? Border.all(color: borderColor!, width: 0.5) : null), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.5), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], + boxShadow: frosted + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], ), child: Stack( clipBehavior: Clip.hardEdge, children: [ + if (frosted) + Positioned.fill( + child: IgnorePointer( + child: ClipRRect( + borderRadius: BorderRadius.circular(34), + child: BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: AppFrost.sigma, + sigmaY: AppFrost.sigma, + ), + backdropGroupKey: backdropKey, + child: const SizedBox.expand(), + ), + ), + ), + ), if (useGradient) Positioned.fill( child: IgnorePointer( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ed95676..540d719 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -300,6 +300,10 @@ "appearanceComposerSubtitle": "Style and background of the message input bar", "appearanceComposerBackgroundStandard": "Default", "appearanceComposerBackgroundFrost": "Frost blur", + "appearanceNavPillTitle": "Switcher style", + "appearanceNavPillSubtitle": "Section switcher on the chats screen", + "appearanceNavPillGlossy": "Glossy", + "appearanceNavPillFrost": "G-FrostBlur", "appearanceGradientTitle": "Gradient", "appearanceGradientSubtitle": "Depth and highlights in Glossy capsules", "appearanceAccentColorTitle": "Accent color", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e9d73f9..813a48a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1586,6 +1586,30 @@ abstract class AppLocalizations { /// **'Frost blur'** String get appearanceComposerBackgroundFrost; + /// No description provided for @appearanceNavPillTitle. + /// + /// In en, this message translates to: + /// **'Switcher style'** + String get appearanceNavPillTitle; + + /// No description provided for @appearanceNavPillSubtitle. + /// + /// In en, this message translates to: + /// **'Section switcher on the chats screen'** + String get appearanceNavPillSubtitle; + + /// No description provided for @appearanceNavPillGlossy. + /// + /// In en, this message translates to: + /// **'Glossy'** + String get appearanceNavPillGlossy; + + /// No description provided for @appearanceNavPillFrost. + /// + /// In en, this message translates to: + /// **'G-FrostBlur'** + String get appearanceNavPillFrost; + /// No description provided for @appearanceGradientTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 131218f..68dc295 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -790,6 +790,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appearanceComposerBackgroundFrost => 'Frost blur'; + @override + String get appearanceNavPillTitle => 'Switcher style'; + + @override + String get appearanceNavPillSubtitle => + 'Section switcher on the chats screen'; + + @override + String get appearanceNavPillGlossy => 'Glossy'; + + @override + String get appearanceNavPillFrost => 'G-FrostBlur'; + @override String get appearanceGradientTitle => 'Gradient'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 257c242..78cb3af 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -792,6 +792,19 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appearanceComposerBackgroundFrost => 'Frost blur'; + @override + String get appearanceNavPillTitle => 'Вид переключателей'; + + @override + String get appearanceNavPillSubtitle => + 'Переключатель разделов на экране чатов'; + + @override + String get appearanceNavPillGlossy => 'Glossy'; + + @override + String get appearanceNavPillFrost => 'G-FrostBlur'; + @override String get appearanceGradientTitle => 'Градиент'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index ba52539..a84c69c 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -265,6 +265,10 @@ "appearanceComposerSubtitle": "Стиль и фон панели ввода сообщений", "appearanceComposerBackgroundStandard": "Default", "appearanceComposerBackgroundFrost": "Frost blur", + "appearanceNavPillTitle": "Вид переключателей", + "appearanceNavPillSubtitle": "Переключатель разделов на экране чатов", + "appearanceNavPillGlossy": "Glossy", + "appearanceNavPillFrost": "G-FrostBlur", "appearanceGradientTitle": "Градиент", "appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах", "appearanceAccentColorTitle": "Акцентный цвет", diff --git a/lib/main.dart b/lib/main.dart index a18762c..601e3c7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -40,6 +40,7 @@ import 'core/config/app_visual_style.dart'; import 'core/config/app_chat_chrome.dart'; import 'core/config/app_composer_background.dart'; import 'core/config/app_composer_style.dart'; +import 'core/config/app_nav_pill_style.dart'; import 'core/config/app_wallpaper_tint.dart'; import 'core/storage/chat_wallpaper_store.dart'; import 'core/utils/wallpaper_seed.dart'; @@ -194,6 +195,7 @@ void main(List args) async { final chatChromeFuture = AppChatChrome.load(); final composerStyleFuture = AppComposerStyle.load(); final composerBackgroundFuture = AppComposerBackground.load(); + final navPillStyleFuture = AppNavPillStyle.load(); final wallpaperTintFuture = AppWallpaperTint.load(); final themeScheduleFuture = AppThemeSchedule.load(); final messageActionsFuture = AppMessageActionsStyle.load(); @@ -247,6 +249,7 @@ void main(List args) async { chatChromeFuture, composerStyleFuture, composerBackgroundFuture, + navPillStyleFuture, wallpaperTintFuture, themeScheduleFuture, messageActionsFuture, @@ -527,7 +530,9 @@ class KometAppState extends State AppAmoled.current.removeListener(_onAmoledChanged); AppThemeSchedule.current.removeListener(_onScheduleChanged); AppWallpaperTint.current.removeListener(_onWallpaperTintChanged); - ChatWallpaperStore.instance.revision.removeListener(_onWallpaperTintChanged); + ChatWallpaperStore.instance.revision.removeListener( + _onWallpaperTintChanged, + ); WidgetsBinding.instance.removeObserver(this); _profileUpdateController.close(); fpsOverlayEnabled.dispose(); @@ -752,8 +757,10 @@ class KometAppState extends State return; } await ChatWallpaperStore.instance.load(); - final wallpaper = - ChatWallpaperStore.instance.get(accountId, kGlobalWallpaperChatId); + final wallpaper = ChatWallpaperStore.instance.get( + accountId, + kGlobalWallpaperChatId, + ); final seed = await computeWallpaperSeed(wallpaper); if (!mounted) return; wallpaperSeed.value = seed; @@ -897,8 +904,8 @@ class KometAppState extends State AppWallpaperTint.current, ]), builder: (context, _) { - final seed = AppWallpaperTint.current.value && - wallpaperSeed.value != null + final seed = + AppWallpaperTint.current.value && wallpaperSeed.value != null ? wallpaperSeed.value : accentSeed.value; final ColorScheme lightBase;