From 53125b60a3a5fefc621f802677ef0af3eb79e4b0 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Fri, 3 Jul 2026 19:07:43 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D0=BB=D0=B5=D0=B3=D0=BE=D0=BD?= =?UTF-8?q?=D1=86=D0=B0=20=D0=B1=D0=BB=D1=8E=D1=80=D0=B0=20=D0=B2=20chat?= =?UTF-8?q?=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_chat_chrome.dart | 43 ++++ lib/frontend/screens/chats/chat_screen.dart | 224 +++++++++++++++--- .../screens/profile/appearance_screen.dart | 66 ++++++ lib/main.dart | 3 + 4 files changed, 303 insertions(+), 33 deletions(-) create mode 100644 lib/core/config/app_chat_chrome.dart diff --git a/lib/core/config/app_chat_chrome.dart b/lib/core/config/app_chat_chrome.dart new file mode 100644 index 0000000..f9e8bd9 --- /dev/null +++ b/lib/core/config/app_chat_chrome.dart @@ -0,0 +1,43 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum ChatChromeStyle { color, blur, none } + +class AppChatChrome { + static const prefKey = 'app_chat_chrome'; + static final ValueNotifier current = + ValueNotifier(ChatChromeStyle.blur); + + static ChatChromeStyle _parse(String? value) { + switch (value) { + case 'color': + return ChatChromeStyle.color; + case 'none': + return ChatChromeStyle.none; + default: + return ChatChromeStyle.blur; + } + } + + static String _encode(ChatChromeStyle value) { + switch (value) { + case ChatChromeStyle.color: + return 'color'; + case ChatChromeStyle.blur: + return 'blur'; + case ChatChromeStyle.none: + return 'none'; + } + } + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return _parse(prefs.getString(prefKey)); + } + + static Future save(ChatChromeStyle value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(prefKey, _encode(value)); + } +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 7d2809c..5c1648f 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -48,6 +48,7 @@ import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_pranks.dart'; import '../../../core/config/app_commands.dart'; import '../../../core/config/app_visual_style.dart'; +import '../../../core/config/app_chat_chrome.dart'; import '../../../core/config/komet_settings.dart'; import '../../../models/attachment.dart'; import '../../../models/sticker.dart'; @@ -183,6 +184,70 @@ class _ButtonClipper extends CustomClipper { bool shouldReclip(_ButtonClipper old) => old.t != t; } +class _FrostedPanel extends StatelessWidget { + final Color tint; + final Border? border; + final Widget child; + + const _FrostedPanel({required this.tint, this.border, required this.child}); + + @override + Widget build(BuildContext context) { + return ClipRect( + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 24, sigmaY: 24), + child: DecoratedBox( + decoration: BoxDecoration(color: tint, border: border), + child: child, + ), + ), + ); + } +} + +class _MeasureSize extends StatefulWidget { + final Widget child; + final ValueChanged onHeight; + + const _MeasureSize({required this.onHeight, required this.child}); + + @override + State<_MeasureSize> createState() => _MeasureSizeState(); +} + +class _MeasureSizeState extends State<_MeasureSize> { + final GlobalKey _key = GlobalKey(); + double _last = -1; + + void _report() { + if (!mounted) return; + final height = _key.currentContext?.size?.height; + if (height == null) return; + if ((height - _last).abs() > 0.5) { + _last = height; + widget.onHeight(height); + } + } + + void _scheduleReport() { + WidgetsBinding.instance.addPostFrameCallback((_) => _report()); + } + + @override + Widget build(BuildContext context) { + _scheduleReport(); + return NotificationListener( + onNotification: (_) { + _scheduleReport(); + return true; + }, + child: SizeChangedLayoutNotifier( + child: SizedBox(key: _key, child: widget.child), + ), + ); + } +} + class ChatScreen extends StatefulWidget { final int chatId; final String name; @@ -330,6 +395,7 @@ class _ChatScreenState extends State int _myId = 0; CachedChat? chat; ChatWallpaper? _wallpaper; + final ValueNotifier _composerHeight = ValueNotifier(96); final ValueNotifier _floatingDate = ValueNotifier(null); Timer? _floatingDateTimer; @@ -357,6 +423,7 @@ class _ChatScreenState extends State _scrollController.addListener(_onScrollForDate); _scrollController.addListener(_maybeLoadMoreHistory); AppVisualStyle.current.addListener(_onVisualStyleChanged); + AppChatChrome.current.addListener(_onVisualStyleChanged); _shimmerController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), @@ -875,6 +942,8 @@ class _ChatScreenState extends State _scrollController.removeListener(_onScrollForDate); _scrollController.removeListener(_maybeLoadMoreHistory); AppVisualStyle.current.removeListener(_onVisualStyleChanged); + AppChatChrome.current.removeListener(_onVisualStyleChanged); + _composerHeight.dispose(); _floatingDateTimer?.cancel(); _floatingDateCurved.dispose(); _floatingDateAnimController.dispose(); @@ -1367,7 +1436,7 @@ class _ChatScreenState extends State Widget _buildComposerArea(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Column( + final content = Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedBuilder( @@ -1449,6 +1518,17 @@ class _ChatScreenState extends State ), ], ); + if (AppChatChrome.current.value != ChatChromeStyle.blur) return content; + return _FrostedPanel( + tint: cs.surfaceContainerHigh.withValues(alpha: 0.55), + border: Border( + top: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.4), + width: 0.5, + ), + ), + child: content, + ); } Widget _buildSelectionBottomBar(ColorScheme cs, Set selected) { @@ -1917,8 +1997,23 @@ class _ChatScreenState extends State PreferredSizeWidget _buildAppBar(ColorScheme cs) { final glossy = AppVisualStyle.current.value == VisualStyle.glossy; final height = glossy ? 76.0 : kToolbarHeight; + final chrome = AppChatChrome.current.value; return AppBar( - backgroundColor: glossy ? Colors.transparent : cs.surfaceContainerHigh, + backgroundColor: chrome == ChatChromeStyle.color + ? (glossy ? Colors.transparent : cs.surfaceContainerHigh) + : Colors.transparent, + flexibleSpace: chrome == ChatChromeStyle.blur + ? _FrostedPanel( + tint: cs.surfaceContainerHigh.withValues(alpha: 0.55), + border: Border( + bottom: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.4), + width: 0.5, + ), + ), + child: const SizedBox.expand(), + ) + : null, foregroundColor: cs.onSurface, surfaceTintColor: Colors.transparent, iconTheme: IconThemeData(color: cs.onSurface), @@ -3511,6 +3606,7 @@ class _ChatScreenState extends State ? _prankPinkTheme(Theme.of(context)) : Theme.of(context); final cs = theme.colorScheme; + final underlap = AppChatChrome.current.value != ChatChromeStyle.color; // TODO: Локализация // TODO: Cклонения @@ -3544,33 +3640,9 @@ class _ChatScreenState extends State ), child: Scaffold( backgroundColor: cs.surface, + extendBodyBehindAppBar: underlap, appBar: _buildAppBar(cs), - body: Column( - children: [ - Expanded( - child: Stack( - children: [ - if (_wallpaper != null) - Positioned.fill( - child: ChatWallpaperView(wallpaper: _wallpaper!), - ), - Positioned.fill( - child: _isLoading && _messages.isEmpty - ? _buildShimmerLoading() - : _buildMessagesList(), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: _buildCommandPanel(), - ), - ], - ), - ), - _buildComposerArea(context), - ], - ), + body: underlap ? _buildUnderlapBody() : _buildColorBody(), ), ), ), @@ -3579,6 +3651,76 @@ class _ChatScreenState extends State ); } + Widget _buildColorBody() { + return Column( + children: [ + Expanded( + child: Stack( + children: [ + if (_wallpaper != null) + Positioned.fill( + child: ChatWallpaperView(wallpaper: _wallpaper!), + ), + Positioned.fill( + child: _isLoading && _messages.isEmpty + ? _buildShimmerLoading() + : _buildMessagesList(), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildCommandPanel(), + ), + ], + ), + ), + _buildComposerArea(context), + ], + ); + } + + Widget _buildUnderlapBody() { + return Stack( + fit: StackFit.expand, + children: [ + if (_wallpaper != null) + Positioned.fill( + child: ChatWallpaperView(wallpaper: _wallpaper!), + ), + Positioned.fill( + child: _isLoading && _messages.isEmpty + ? _buildShimmerLoading() + : _buildMessagesList(), + ), + ValueListenableBuilder( + valueListenable: _composerHeight, + builder: (context, height, _) => Positioned( + left: 0, + right: 0, + bottom: height, + child: _buildCommandPanel(), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Builder( + builder: (context) => MediaQuery.removePadding( + context: context, + removeTop: true, + child: _MeasureSize( + onHeight: (value) => _composerHeight.value = value, + child: _buildComposerArea(context), + ), + ), + ), + ), + ], + ); + } + Widget _buildMessagesList() { return ValueListenableBuilder( valueListenable: _messagesRev, @@ -3586,6 +3728,22 @@ class _ChatScreenState extends State ); } + EdgeInsets _messagesListPadding(BuildContext context) { + if (AppChatChrome.current.value == ChatChromeStyle.color) { + return const EdgeInsets.symmetric(vertical: 8); + } + final topInset = MediaQuery.paddingOf(context).top; + return EdgeInsets.only( + top: topInset + 8, + bottom: _composerHeight.value + 8, + ); + } + + double _floatingDateTop(BuildContext context) { + if (AppChatChrome.current.value == ChatChromeStyle.color) return 8; + return MediaQuery.paddingOf(context).top + 8; + } + Widget _buildLoadMoreIndicator() { final cs = Theme.of(context).colorScheme; return Padding( @@ -3620,14 +3778,14 @@ class _ChatScreenState extends State return Stack( key: _listKey, children: [ - ValueListenableBuilder( - valueListenable: _otherReadTime, - builder: (context, _, _) => ValueListenableBuilder( + ListenableBuilder( + listenable: Listenable.merge([_otherReadTime, _composerHeight]), + builder: (context, _) => ValueListenableBuilder( valueListenable: AppCacheExtent.current, builder: (context, cacheExtent, _) => ListView.builder( controller: _scrollController, reverse: true, - padding: const EdgeInsets.symmetric(vertical: 8), + padding: _messagesListPadding(context), cacheExtent: cacheExtent, itemCount: items.length + (_isLoadingMore ? 1 : 0), itemBuilder: (context, index) { @@ -3767,7 +3925,7 @@ class _ChatScreenState extends State ), ), Positioned( - top: 8, + top: _floatingDateTop(context), left: 0, right: 0, child: IgnorePointer( diff --git a/lib/frontend/screens/profile/appearance_screen.dart b/lib/frontend/screens/profile/appearance_screen.dart index 8812720..974f936 100644 --- a/lib/frontend/screens/profile/appearance_screen.dart +++ b/lib/frontend/screens/profile/appearance_screen.dart @@ -10,6 +10,7 @@ import '../../../core/config/app_bubble_behavior.dart'; import '../../../core/config/app_bubble_shape.dart'; import '../../../core/config/app_pill_gradient.dart'; import '../../../core/config/app_visual_style.dart'; +import '../../../core/config/app_chat_chrome.dart'; import '../../../core/utils/bubble_radius.dart'; import '../../../core/utils/haptics.dart'; import '../../../main.dart'; @@ -115,6 +116,8 @@ class _AppearanceScreenState extends State { const SizedBox(height: 12), const _VisualStyleCard(), const SizedBox(height: 12), + const _ChatChromeCard(), + const SizedBox(height: 12), const _GradientToggleCard(), ], ), @@ -181,6 +184,69 @@ class _VisualStyleCard extends StatelessWidget { } } +class _ChatChromeCard extends StatelessWidget { + const _ChatChromeCard(); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + 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( + 'Элементы экрана чата', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Фон панелей сверху и снизу: цвет, размытие или прозрачно. ' + 'При размытии и прозрачности сообщения заходят под панели', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: AppChatChrome.current, + builder: (context, current, _) { + return SegmentedButton( + segments: const [ + ButtonSegment( + value: ChatChromeStyle.color, + label: Text('Цвет'), + ), + ButtonSegment( + value: ChatChromeStyle.blur, + label: Text('Блюр'), + ), + ButtonSegment( + value: ChatChromeStyle.none, + label: Text('Нет'), + ), + ], + selected: {current}, + onSelectionChanged: (set) { + if (set.isNotEmpty) { + Haptics.selection(); + AppChatChrome.save(set.first); + } + }, + ); + }, + ), + ], + ), + ); + } +} + class _GradientToggleCard extends StatelessWidget { const _GradientToggleCard(); diff --git a/lib/main.dart b/lib/main.dart index cfbe7cf..7635a80 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,6 +33,7 @@ import 'core/config/app_link_preview.dart'; import 'core/config/app_media_cache.dart'; import 'core/config/app_pill_gradient.dart'; import 'core/config/app_visual_style.dart'; +import 'core/config/app_chat_chrome.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'core/config/app_digital_id_mode.dart'; @@ -123,6 +124,7 @@ void main() async { final amoledFuture = AppAmoled.load(); final pillGradientFuture = AppPillGradient.load(); final visualStyleFuture = AppVisualStyle.load(); + final chatChromeFuture = AppChatChrome.load(); final themeScheduleFuture = AppThemeSchedule.load(); final messageActionsFuture = AppMessageActionsStyle.load(); final swipeBackFuture = AppSwipeBackDesktop.load(); @@ -170,6 +172,7 @@ void main() async { AppAmoled.current.value = await amoledFuture; AppPillGradient.current.value = await pillGradientFuture; AppVisualStyle.current.value = await visualStyleFuture; + AppChatChrome.current.value = await chatChromeFuture; AppThemeSchedule.current.value = await themeScheduleFuture; AppMessageActionsStyle.current.value = await messageActionsFuture; AppSwipeBackDesktop.current.value = await swipeBackFuture;