diff --git a/lib/core/config/app_amoled.dart b/lib/core/config/app_amoled.dart index e4a8c77..e66b830 100644 --- a/lib/core/config/app_amoled.dart +++ b/lib/core/config/app_amoled.dart @@ -5,10 +5,13 @@ import 'persisted_setting.dart'; class AppAmoled { static const prefKey = 'app_amoled'; + static bool get systemIsDark => + PlatformDispatcher.instance.platformBrightness == Brightness.dark; + static final _setting = PersistedSetting( prefKey: prefKey, - defaultValue: false, - read: (prefs, key) => prefs.getBool(key), + defaultValue: systemIsDark, + read: (prefs, key) => prefs.getBool(key) ?? systemIsDark, write: (prefs, key, value) async { await prefs.setBool(key, value); }, diff --git a/lib/core/config/app_composer_background.dart b/lib/core/config/app_composer_background.dart index e37b115..de43ec0 100644 --- a/lib/core/config/app_composer_background.dart +++ b/lib/core/config/app_composer_background.dart @@ -19,7 +19,7 @@ class AppComposerBackground { static final _setting = PersistedEnum( prefKey: prefKey, - defaultValue: ComposerBackground.standard, + defaultValue: ComposerBackground.frostBlur, encode: (value) => value.name, decode: _parse, ); @@ -30,6 +30,9 @@ class AppComposerBackground { static Future save(ComposerBackground value) => _setting.save(value); - static ComposerBackground _parse(String? val) => - enumFromName(ComposerBackground.values, val, ComposerBackground.standard); + static ComposerBackground _parse(String? val) => enumFromName( + ComposerBackground.values, + val, + ComposerBackground.frostBlur, + ); } diff --git a/lib/core/config/app_frost.dart b/lib/core/config/app_frost.dart index aca7c83..0c21828 100644 --- a/lib/core/config/app_frost.dart +++ b/lib/core/config/app_frost.dart @@ -3,22 +3,13 @@ import 'package:flutter/material.dart'; class AppFrost { static const double sigma = 34; static const double panelSigma = 24; + static const double glassAlpha = 0.28; + static const double blurPanelAlpha = 0.55; - static Color panelTint(ColorScheme cs) => cs.surface.withValues(alpha: 0.38); + static Color glassTint(ColorScheme cs, [double alpha = glassAlpha]) => + cs.surfaceContainerHigh.withValues(alpha: alpha); - static Color blurPanelTint(ColorScheme cs) => - cs.surfaceContainerHigh.withValues(alpha: 0.55); - - 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); + static Color blurPanelTint(ColorScheme cs) => glassTint(cs, blurPanelAlpha); static BorderSide hairline(ColorScheme cs) => BorderSide(color: cs.outlineVariant.withValues(alpha: 0.4), width: 0.5); diff --git a/lib/core/config/app_nav_pill_style.dart b/lib/core/config/app_nav_pill_style.dart index 57eb9cc..9d05f58 100644 --- a/lib/core/config/app_nav_pill_style.dart +++ b/lib/core/config/app_nav_pill_style.dart @@ -19,7 +19,7 @@ class AppNavPillStyle { static final _setting = PersistedEnum( prefKey: prefKey, - defaultValue: NavPillStyle.glossy, + defaultValue: NavPillStyle.frostBlur, encode: (value) => value.name, decode: _parse, ); @@ -31,5 +31,5 @@ class AppNavPillStyle { static Future save(NavPillStyle value) => _setting.save(value); static NavPillStyle _parse(String? val) => - enumFromName(NavPillStyle.values, val, NavPillStyle.glossy); + enumFromName(NavPillStyle.values, val, NavPillStyle.frostBlur); } diff --git a/lib/frontend/screens/chats/chat/view/chat_header.dart b/lib/frontend/screens/chats/chat/view/chat_header.dart index 0592d69..f51d14b 100644 --- a/lib/frontend/screens/chats/chat/view/chat_header.dart +++ b/lib/frontend/screens/chats/chat/view/chat_header.dart @@ -20,6 +20,7 @@ import 'package:komet/models/story.dart'; class ChatHeaderRow extends StatelessWidget { final bool glossy; final bool frosted; + final bool backdropVisible; final bool liquid; final BackdropKey? backdropKey; final ColorScheme cs; @@ -46,6 +47,7 @@ class ChatHeaderRow extends StatelessWidget { super.key, required this.glossy, required this.frosted, + this.backdropVisible = true, this.liquid = false, this.backdropKey, required this.cs, @@ -73,9 +75,10 @@ class ChatHeaderRow extends StatelessWidget { Widget build(BuildContext context) => glossy ? _glossyRow(context) : _materialRow(context); - Color? get _pillColor => frosted || liquid ? AppFrost.pillTint(cs) : null; + Color? get _pillColor => frosted || liquid ? AppFrost.glassTint(cs) : null; - double? get _pillBlur => frosted && !liquid ? AppFrost.sigma : null; + double? get _pillBlur => + frosted && !liquid && backdropVisible ? AppFrost.sigma : null; Widget _glossyRow(BuildContext context) { final nameStyle = TextStyle( diff --git a/lib/frontend/screens/chats/chat/view/composer_input.dart b/lib/frontend/screens/chats/chat/view/composer_input.dart index 11fab2e..9b1e8a3 100644 --- a/lib/frontend/screens/chats/chat/view/composer_input.dart +++ b/lib/frontend/screens/chats/chat/view/composer_input.dart @@ -408,7 +408,7 @@ class ComposerInputBar extends StatelessWidget { : recording ? cs.error : _frost - ? AppFrost.inputTint(cs) + ? AppFrost.glassTint(cs) : cs.surfaceContainerHighest, onTap: (hasText || @@ -523,7 +523,7 @@ class ComposerInputBar extends StatelessWidget { if (_flat) return child; return GlossyPill( color: _translucent - ? AppFrost.inputTint(cs) + ? AppFrost.glassTint(cs) : Color.alphaBlend( cs.surfaceContainerHighest.withValues(alpha: 0.92), cs.surface, @@ -741,7 +741,7 @@ class ComposerInputBar extends StatelessWidget { if (!_translucent && chrome != ChatChromeStyle.transparent) return child; return GlassSurface( liquid: _liquid, - frostTint: AppFrost.panelTint(cs), + frostTint: AppFrost.glassTint(cs), border: Border(top: AppFrost.hairline(cs)), backdropKey: backdropKey, child: child, diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 3c050a5..4ca4c5d 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -2318,7 +2318,7 @@ class _ChatListScreenState extends State return GlossyPill( onTap: _toggleFab, color: frost || liquid - ? AppFrost.fabTint(cs) + ? AppFrost.glassTint(cs) : cs.primaryContainer, blurSigma: frost ? AppFrost.sigma diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 9986dfa..c5a37c9 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -560,6 +560,12 @@ class _ChatScreenState extends State static const double _glossyHeaderHeight = 76.0; static const double _glossySearchHeight = 58.0; static const double _pinnedBannerLift = 6.0; + static const double _unreadSeparatorHeight = 30.0; + static const double _unreadSeparatorInset = 72.0; + static const double _unreadAnchorFallbackAlignment = 0.3; + static const int _jumpStallLimit = 8; + static const int _jumpFrameLimit = 240; + static const double _jumpStepMaxScreens = 4.0; final BackdropKey _barBackdrop = BackdropKey(); final BackdropKey _pillBackdrop = BackdropKey(); @@ -1038,18 +1044,39 @@ class _ChatScreenState extends State } } - void _positionToMessage(String messageId, double alignment) { + double _unreadAnchorAlignment() { + final listBox = _listKey.currentContext?.findRenderObject(); + if (listBox is! RenderBox || listBox.size.height <= 0) { + return _unreadAnchorFallbackAlignment; + } + final glossy = AppVisualStyle.current.value.glossyChrome; + final chromeBottom = _effectiveChrome == ChatChromeStyle.color + ? 0.0 + : MediaQuery.paddingOf(context).top + + (glossy ? _glossyHeaderHeight : kToolbarHeight) + + _pinnedBannerHeight.value; + final desiredTop = + chromeBottom + _unreadSeparatorHeight + _unreadSeparatorInset; + return (desiredTop / listBox.size.height).clamp(0.0, 0.5); + } + + void _positionToMessage(String messageId) { _pinnedMessageId = messageId; - _pinnedAlignment = alignment.clamp(0.0, 1.0); + _jumpCacheExtent.value = _jumpCacheExtentPx; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + _pinnedAlignment = _unreadAnchorAlignment(); _scrollToLoadedMessage( messageId, alignment: _pinnedAlignment, highlight: false, notifyIfMissing: false, onSettled: () { - if (mounted) setState(_markPositioned); + if (!mounted) return; + setState(_markPositioned); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _jumpCacheExtent.value = null; + }); }, ); }); @@ -1105,7 +1132,7 @@ class _ChatScreenState extends State } if (firstUnread > 0 || !_hasMoreHistory) { _initialPositionDone = true; - _positionToMessage(_messages[firstUnread].id, 0.15); + _positionToMessage(_messages[firstUnread].id); } else { _positioningInFlight = true; unawaited(_loadUntilUnreadReady()); @@ -1222,7 +1249,7 @@ class _ChatScreenState extends State _positioningInFlight = false; _initialPositionDone = true; if (idx >= 0) { - _positionToMessage(_messages[idx].id, 0.15); + _positionToMessage(_messages[idx].id); } else { setState(_markPositioned); } @@ -2673,7 +2700,7 @@ class _ChatScreenState extends State } return _FrostedPanel( sigma: AppFrost.sigma, - tint: AppFrost.panelTint(cs), + tint: AppFrost.glassTint(cs), border: Border(top: AppFrost.hairline(cs)), backdropKey: _barBackdrop, child: child, @@ -3095,7 +3122,7 @@ class _ChatScreenState extends State : (chrome == ChatChromeStyle.transparent && !glossy) ? _FrostedPanel( sigma: AppFrost.sigma, - tint: AppFrost.panelTint(cs), + tint: AppFrost.glassTint(cs), border: Border(bottom: AppFrost.hairline(cs)), backdropKey: _barBackdrop, child: const SizedBox.expand(), @@ -3136,6 +3163,7 @@ class _ChatScreenState extends State glossy: glossy, frosted: glossy && chrome == ChatChromeStyle.transparent, + backdropVisible: t == 0 && s == 0, liquid: _liquidChrome, backdropKey: _pillBackdrop, cs: cs, @@ -4835,12 +4863,7 @@ class _ChatScreenState extends State messageId, ).currentContext?.findRenderObject(); if (laidOut is! RenderBox || !laidOut.attached) { - var below = 0.0; - for (var i = pos + 1; i < items.length; i++) { - below += items[i] is _MessageItem ? _avgMessageHeight : 44.0; - } - final maxExtent = _scrollController.position.maxScrollExtent; - _scrollController.jumpTo(below.clamp(0.0, maxExtent).toDouble()); + _jumpNearMessage(messageId); } if (highlight) { @@ -4858,10 +4881,72 @@ class _ChatScreenState extends State ); } + ({int oldest, int newest}) _visibleItemRange( + List items, + RenderBox listBox, + ) { + var oldest = -1; + var newest = -1; + final viewportBottom = listBox.size.height; + for (var i = 0; i < items.length; i++) { + final item = items[i]; + if (item is! _MessageItem) continue; + final box = _messageKeys[item.message.id]?.currentContext + ?.findRenderObject(); + if (box is! RenderBox || !box.attached) continue; + final top = box.localToGlobal(Offset.zero, ancestor: listBox).dy; + if (top + box.size.height <= 0 || top >= viewportBottom) continue; + if (oldest == -1) oldest = i; + newest = i; + } + return (oldest: oldest, newest: newest); + } + + double _jumpStepScreens(int index, ({int oldest, int newest}) visible) { + if (visible.oldest == -1) return 1; + final perScreen = visible.newest - visible.oldest + 1; + if (perScreen <= 0) return 1; + final away = index < visible.oldest + ? visible.oldest - index + : index - visible.newest; + return (away / perScreen).clamp(1.0, _jumpStepMaxScreens); + } + + bool _jumpNearMessage(String messageId) { + if (!_scrollController.hasClients) return false; + final listBox = _listKey.currentContext?.findRenderObject(); + if (listBox is! RenderBox || listBox.size.height <= 0) return false; + final items = _buildCombinedItems(); + final index = items.indexWhere( + (it) => it is _MessageItem && it.message.id == messageId, + ); + if (index == -1) return false; + + final visible = _visibleItemRange(items, listBox); + final position = _scrollController.position; + final step = position.viewportDimension * _jumpStepScreens(index, visible); + final double next; + if (visible.oldest == -1 || index < visible.oldest) { + next = position.pixels + step; + } else if (index > visible.newest) { + next = position.pixels - step; + } else { + return false; + } + final clamped = next.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if ((clamped - position.pixels).abs() < 0.5) return false; + _scrollController.jumpTo(clamped); + return true; + } + void _alignLoadedMessage( String messageId, double alignment, int attempt, { + int frames = 0, VoidCallback? onSettled, }) { if (!mounted || !_scrollController.hasClients) { @@ -4871,15 +4956,17 @@ class _ChatScreenState extends State final listBox = _listKey.currentContext?.findRenderObject(); final box = _keyForMessage(messageId).currentContext?.findRenderObject(); if (listBox is! RenderBox || box is! RenderBox || !box.attached) { - if (attempt >= 8) { + if (attempt >= _jumpStallLimit || frames >= _jumpFrameLimit) { onSettled?.call(); return; } + final moved = _jumpNearMessage(messageId); WidgetsBinding.instance.addPostFrameCallback( (_) => _alignLoadedMessage( messageId, alignment, - attempt + 1, + moved ? 0 : attempt + 1, + frames: frames + 1, onSettled: onSettled, ), ); @@ -4899,7 +4986,8 @@ class _ChatScreenState extends State if (viewportHeight <= 0 || delta.abs() <= 0.5 || (target - pos.pixels).abs() <= 0.5 || - attempt >= 8) { + attempt >= _jumpStallLimit || + frames >= _jumpFrameLimit) { onSettled?.call(); return; } @@ -4910,6 +4998,7 @@ class _ChatScreenState extends State messageId, alignment, attempt + 1, + frames: frames + 1, onSettled: onSettled, ), ); @@ -5790,52 +5879,59 @@ class _ChatScreenState extends State builder: (context, child) { final t = _scrollDownCurved.value; if (t == 0) return const SizedBox.shrink(); + final backdropVisible = t >= 1; return Opacity( opacity: t, - child: Transform.scale(scale: 0.82 + 0.18 * t, child: child), + child: Transform.scale( + scale: 0.82 + 0.18 * t, + child: SizedBox( + width: 46, + height: 46, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: GlossyPill( + color: frosted || _liquidChrome + ? AppFrost.glassTint(cs) + : null, + blurSigma: frosted && !_liquidChrome && backdropVisible + ? AppFrost.sigma + : null, + liquid: _liquidChrome, + backdropKey: _pillBackdrop, + elevated: true, + onTap: _onScrollDownTap, + child: child!, + ), + ), + Positioned( + top: -5, + right: -3, + child: ValueListenableBuilder( + valueListenable: _newMessageCount, + builder: (context, count, _) => count <= 0 + ? const SizedBox.shrink() + : AnimatedValueSwap( + value: count > 99 ? 100 : count, + alignment: Alignment.centerRight, + builder: (context, value) => + _unreadBadge(cs, value), + ), + ), + ), + ], + ), + ), + ), ); }, - child: SizedBox( - width: 46, - height: 46, - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: GlossyPill( - color: frosted || _liquidChrome - ? AppFrost.pillTint(cs) - : null, - blurSigma: frosted && !_liquidChrome ? AppFrost.sigma : null, - liquid: _liquidChrome, - backdropKey: _pillBackdrop, - elevated: true, - onTap: _onScrollDownTap, - child: Center( - child: Icon( - Symbols.keyboard_arrow_down, - color: cs.onSurface, - weight: 500, - size: 26, - ), - ), - ), - ), - Positioned( - top: -5, - right: -3, - child: ValueListenableBuilder( - valueListenable: _newMessageCount, - builder: (context, count, _) => count <= 0 - ? const SizedBox.shrink() - : AnimatedValueSwap( - value: count > 99 ? 100 : count, - alignment: Alignment.centerRight, - builder: (context, value) => _unreadBadge(cs, value), - ), - ), - ), - ], + child: Center( + child: Icon( + Symbols.keyboard_arrow_down, + color: cs.onSurface, + weight: 500, + size: 26, ), ), ), @@ -6862,7 +6958,7 @@ class _PinnedMessageBanner extends StatelessWidget { final cs = Theme.of(context).colorScheme; final content = Material( color: frosted - ? AppFrost.panelTint(cs) + ? AppFrost.glassTint(cs) : floating ? cs.surfaceContainerHigh.withValues(alpha: 0.92) : cs.surfaceContainerHigh, diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index d079718..cb7d5dd 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -797,7 +797,7 @@ class _AttachmentSheetState extends State { backgroundColor: liquid ? null : (frost - ? AppFrost.navPillTint(cs) + ? AppFrost.glassTint(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 1f9e768..1c3d0b5 100644 --- a/lib/frontend/widgets/sliding_pill_nav.dart +++ b/lib/frontend/widgets/sliding_pill_nav.dart @@ -132,7 +132,7 @@ class SlidingPillNav extends StatelessWidget { final base = liquid ? (translucent ? backgroundColor! : AppLiquidGlass.navTint(cs)) : (backgroundColor ?? - (frost ? AppFrost.navPillTint(cs) : cs.surfaceContainerHigh)); + (frost ? AppFrost.glassTint(cs) : cs.surfaceContainerHigh)); final useGradient = glossy && gradient && !liquid; final frosted = frost && !liquid && base.a < 1; diff --git a/lib/frontend/widgets/sticker_panel.dart b/lib/frontend/widgets/sticker_panel.dart index 692d268..14fda4b 100644 --- a/lib/frontend/widgets/sticker_panel.dart +++ b/lib/frontend/widgets/sticker_panel.dart @@ -254,7 +254,7 @@ class _StickerPanelState extends State height: widget.height, child: GlassSurface( liquid: false, - frostTint: AppFrost.panelTint(cs), + frostTint: AppFrost.glassTint(cs), border: Border(top: AppFrost.hairline(cs)), child: SafeArea( top: false,