From 6dc18353775041ecfcfa485fe2be0e88c98659b6 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 22 Aug 2026 16:25:23 +0700 Subject: [PATCH] =?UTF-8?q?=D1=81=D1=8B=D0=BD=20=D0=BD=D0=B5=20=D0=B5?= =?UTF-8?q?=D1=81=D1=82=20=D0=BA=D0=B0=D0=BC=D0=BD=D0=B8(?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chats/chat/view/composer_input.dart | 241 +++++++++++------- lib/frontend/screens/chats/chat_screen.dart | 76 ++++-- test/chat_bar_alignment_test.dart | 196 ++++++++++++++ 3 files changed, 399 insertions(+), 114 deletions(-) create mode 100644 test/chat_bar_alignment_test.dart diff --git a/lib/frontend/screens/chats/chat/view/composer_input.dart b/lib/frontend/screens/chats/chat/view/composer_input.dart index 15b1441..d413b50 100644 --- a/lib/frontend/screens/chats/chat/view/composer_input.dart +++ b/lib/frontend/screens/chats/chat/view/composer_input.dart @@ -58,6 +58,7 @@ class ComposerInputBar extends StatelessWidget { this.forceSend = false, this.hintText = 'Message', this.bottomSafe = true, + this.vignette = false, }); final String chatType; @@ -96,6 +97,7 @@ class ComposerInputBar extends StatelessWidget { final bool forceSend; final String hintText; final bool bottomSafe; + final bool vignette; @override Widget build(BuildContext context) { @@ -195,9 +197,9 @@ class ComposerInputBar extends StatelessWidget { children: [ _messagePreview(cs, forwards), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 8.0, + padding: EdgeInsets.symmetric( + horizontal: _barSideInset, + vertical: _barVerticalInset, ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, @@ -205,8 +207,8 @@ class ComposerInputBar extends StatelessWidget { Expanded( child: AnimatedContainer( duration: const Duration(milliseconds: 200), - constraints: const BoxConstraints( - minHeight: 54, + constraints: BoxConstraints( + minHeight: _controlSize, maxHeight: 180, ), child: _fieldSurface( @@ -215,95 +217,119 @@ class ComposerInputBar extends StatelessWidget { alignment: Alignment.center, children: [ AnimatedBuilder( - animation: attachAnim, + animation: Listenable.merge([ + voiceRec.isRecording, + note.isRecording, + ]), builder: (context, child) { - final t = attachAnim.value; + final recording = + voiceRec.isRecording.value || + note.isRecording.value; return IgnorePointer( - ignoring: t > 0.5, - child: Opacity( - opacity: (1 - t).clamp(0.0, 1.0), + ignoring: recording, + child: AnimatedOpacity( + opacity: recording ? 0 : 1, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, child: child, ), ); }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 14, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (showStickerButton) ...[ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onToggleStickerPanel, - child: Icon( - Symbols.face, - color: mutedIcon, - size: 24, - weight: 400, - ), - ), - const SizedBox(width: 12), - ], - Expanded( - child: Focus( - onKeyEvent: (node, event) { - if (event is KeyDownEvent && - event.logicalKey == - LogicalKeyboardKey.enter && - !HardwareKeyboard - .instance - .isShiftPressed) { - if (hasText.value || - hasForward || - forceSend) { - onSendText(); - } - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: TextField( - controller: messageController, - focusNode: messageFocusNode, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, + child: AnimatedBuilder( + animation: attachAnim, + builder: (context, child) { + final t = attachAnim.value; + return IgnorePointer( + ignoring: t > 0.5, + child: Opacity( + opacity: (1 - t).clamp(0.0, 1.0), + child: child, + ), + ); + }, + child: Padding( + padding: EdgeInsets.only( + left: _fieldSideInset, + right: _fieldTrailingInset, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (showStickerButton) ...[ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onToggleStickerPanel, + child: Icon( + Symbols.face, + color: mutedIcon, + size: 24, + weight: 400, ), - maxLines: null, - keyboardType: TextInputType.multiline, - textCapitalization: - TextCapitalization.sentences, - textAlignVertical: - TextAlignVertical.center, - contextMenuBuilder: contextMenuBuilder, - decoration: InputDecoration( - hintText: hintText, - hintStyle: TextStyle( - color: cs.onSurfaceVariant, + ), + const SizedBox(width: 12), + ], + Expanded( + child: Focus( + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + event.logicalKey == + LogicalKeyboardKey.enter && + !HardwareKeyboard + .instance + .isShiftPressed) { + if (hasText.value || + hasForward || + forceSend) { + onSendText(); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: TextField( + controller: messageController, + focusNode: messageFocusNode, + style: TextStyle( + color: cs.onSurface, fontSize: 16, ), - border: InputBorder.none, - isDense: true, - contentPadding: - const EdgeInsets.symmetric( - vertical: 14, - ), + maxLines: null, + keyboardType: TextInputType.multiline, + textCapitalization: + TextCapitalization.sentences, + textAlignVertical: + TextAlignVertical.center, + contextMenuBuilder: + contextMenuBuilder, + decoration: InputDecoration( + hintText: hintText, + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + border: InputBorder.none, + isDense: true, + contentPadding: + const EdgeInsets.symmetric( + vertical: 10, + ), + ), ), ), ), - ), - if (showAttachButton) - _AttachButton( - hasText: hasText, - onOpen: onOpenAttach, - onLongOpen: onOpenAttachScheduled, - uploadStatus: uploadStatus, - mutedIcon: mutedIcon, - cs: cs, - ), - ], + if (showAttachButton) + _AttachButton( + hasText: hasText, + onOpen: onOpenAttach, + onLongOpen: onOpenAttachScheduled, + uploadStatus: uploadStatus, + mutedIcon: mutedIcon, + cs: cs, + slot: _attachSlot, + leading: _attachLeading, + ), + ], + ), ), ), ), @@ -312,7 +338,7 @@ class ComposerInputBar extends StatelessWidget { right: 0, bottom: 0, child: SizedBox( - height: 54, + height: _controlSize, child: AnimatedBuilder( animation: attachAnim, builder: (context, child) { @@ -385,7 +411,7 @@ class ComposerInputBar extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(width: 8), + SizedBox(width: _actionGap), AnimatedBuilder( animation: attachAnim, builder: (context, child) { @@ -458,8 +484,8 @@ class ComposerInputBar extends StatelessWidget { ? onScheduleMessage : null, child: SizedBox( - width: 54, - height: 54, + width: _controlSize, + height: _controlSize, child: Center( child: ComposerMorphIcon( action: sendMode @@ -543,12 +569,29 @@ class ComposerInputBar extends StatelessWidget { bool get _translucent => _frost || _liquid; + double get _controlSize => _flat ? 48 : 54; + + double get _barSideInset => _flat ? 0 : 12; + + double get _barVerticalInset => _flat ? 4 : 8; + + double get _fieldSideInset => _flat ? 12 : 14; + + double get _fieldTrailingInset => _flat ? 0 : 14; + + double get _actionGap => _flat ? 0 : 8; + + double get _attachSlot => _flat ? 48 : 36; + + double get _attachLeading => _flat ? 0 : 12; + Widget _barSurface(ColorScheme cs, Widget child) { if (!_flat || _translucent) return child; + if (chrome == ChatChromeStyle.blur) return child; return DecoratedBox( decoration: BoxDecoration( color: cs.surface, - border: Border(top: AppFrost.hairline(cs)), + border: vignette ? null : Border(top: AppFrost.hairline(cs)), ), child: child, ); @@ -614,7 +657,7 @@ class ComposerInputBar extends StatelessWidget { blurSigma: _frost ? AppFrost.sigma : null, liquid: _liquid, backdropKey: backdropKey, - borderRadius: BorderRadius.circular(27), + borderRadius: BorderRadius.circular(_controlSize / 2), onTap: onTap, onLongPress: onLongPress, keepInkLayer: true, @@ -805,8 +848,8 @@ class ComposerInputBar extends StatelessWidget { alignment: Alignment.center, children: [ Positioned( - left: 27 - glow / 2, - top: 27 - glow / 2, + left: _controlSize / 2 - glow / 2, + top: _controlSize / 2 - glow / 2, child: Container( width: glow, height: glow, @@ -845,7 +888,7 @@ class ComposerInputBar extends StatelessWidget { ValueListenable lockDrag, ) { return Positioned( - bottom: 62, + bottom: _controlSize + 8, child: ValueListenableBuilder( valueListenable: lockDrag, builder: (context, lock, _) => Opacity( @@ -890,12 +933,8 @@ class ComposerInputBar extends StatelessWidget { } Widget _recordingIndicator(ColorScheme cs, bool video) { - return Container( - color: Color.alphaBlend( - cs.surfaceContainerHighest.withValues(alpha: 0.92), - cs.surface, - ), - padding: const EdgeInsets.symmetric(horizontal: 16), + return Padding( + padding: EdgeInsets.symmetric(horizontal: _fieldSideInset), child: Row( children: [ if (video) @@ -1016,6 +1055,8 @@ class _AttachButton extends StatelessWidget { final ValueListenable uploadStatus; final Color mutedIcon; final ColorScheme cs; + final double slot; + final double leading; const _AttachButton({ required this.hasText, @@ -1024,6 +1065,8 @@ class _AttachButton extends StatelessWidget { required this.uploadStatus, required this.mutedIcon, required this.cs, + required this.slot, + required this.leading, }); @override @@ -1043,7 +1086,7 @@ class _AttachButton extends StatelessWidget { final onLongPress = disabled ? null : onLongOpen; return AnimatedContainer( duration: const Duration(milliseconds: 200), - width: isText ? 0 : 36, + width: isText ? 0 : slot, child: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: isText ? 0 : 1, @@ -1054,7 +1097,7 @@ class _AttachButton extends StatelessWidget { onTap: onTap, onLongPress: onLongPress, child: Padding( - padding: const EdgeInsets.only(left: 12), + padding: EdgeInsets.only(left: leading), child: Stack( alignment: Alignment.center, children: [ diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 03253a6..0c4fee8 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -581,6 +581,9 @@ 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 _edgeFadeHeight = 24.0; + static const double _scrollDownSize = 46.0; + static const double _materialIconSlot = 48.0; static const double _unreadSeparatorHeight = 30.0; static const double _unreadSeparatorInset = 72.0; static const double _unreadAnchorFallbackAlignment = 0.3; @@ -610,6 +613,18 @@ class _ChatScreenState extends State bool get _composerUnderlap => AppChatChrome.current.value != ChatChromeStyle.color || _composerFrosted; + bool get _materialComposer => + !ComposerChrome.isGlossy(AppComposerStyle.current.value); + + bool get _composerPaintsSurface { + if (!_commentsMode && + widget.chatType == 'CHANNEL' && + _pendingForwards.value.isEmpty) { + return false; + } + return _materialComposer && !_composerFrosted; + } + bool get _liquidChrome => AppVisualStyle.current.value.glossyChrome && ChatChromeMaterial.isLiquid(AppChatChrome.current.value); @@ -2679,6 +2694,7 @@ class _ChatScreenState extends State bottomSafe: _stickers.anim.value == 0, chatType: _commentsMode ? 'CHAT' : widget.chatType, chrome: _effectiveChrome, + vignette: _chromeVignette, style: AppComposerStyle.current.value, background: AppComposerBackground.current.value, backdropKey: _pillBackdrop, @@ -3116,6 +3132,11 @@ class _ChatScreenState extends State ? ui.lerpDouble(_glossyHeaderHeight, _glossySearchHeight, searchT)! : kToolbarHeight; final chrome = _effectiveChrome; + final barExtent = MediaQuery.paddingOf(context).top + height; + final fadeStop = ((barExtent - _edgeFadeHeight) / barExtent).clamp( + 0.0, + 1.0, + ); return AppBar( backgroundColor: chrome == ChatChromeStyle.color ? (glossy ? Colors.transparent : cs.surfaceContainerHigh) @@ -3139,7 +3160,7 @@ class _ChatScreenState extends State cs.surface, cs.surface.withValues(alpha: 0.0), ], - stops: const [0.0, 0.72, 1.0], + stops: [0.0, fadeStop, 1.0], ), ), child: const SizedBox.expand(), @@ -5494,20 +5515,28 @@ class _ChatScreenState extends State senderAvatar: _searchSenderAvatar, ), if (vignette) ...[ - Positioned( - top: 0, - left: 0, - right: 0, - child: _buildEdgeVignette(cs, top: true), - ), - ValueListenableBuilder( - valueListenable: _composerHeight, - builder: (context, height, _) => Positioned( + if (AppVisualStyle.current.value.glossyChrome) + Positioned( + top: 0, left: 0, right: 0, - bottom: 0, - child: _buildEdgeVignette(cs, top: false, height: height), + child: _buildEdgeVignette(cs, top: true), ), + ValueListenableBuilder( + valueListenable: _composerHeight, + builder: (context, height, _) => _composerPaintsSurface + ? Positioned( + left: 0, + right: 0, + bottom: height, + child: _buildEdgeFade(cs), + ) + : Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildEdgeVignette(cs, top: false, height: height), + ), ), ], Positioned( @@ -5554,6 +5583,21 @@ class _ChatScreenState extends State ); } + Widget _buildEdgeFade(ColorScheme cs) { + return IgnorePointer( + child: Container( + height: _edgeFadeHeight, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [cs.surface, cs.surface.withValues(alpha: 0.0)], + ), + ), + ), + ); + } + Widget _buildEdgeVignette( ColorScheme cs, { required bool top, @@ -5956,7 +6000,9 @@ class _ChatScreenState extends State return ValueListenableBuilder( valueListenable: _composerHeight, builder: (context, height, child) => Positioned( - right: 16, + right: _materialComposer + ? (_materialIconSlot - _scrollDownSize) / 2 + : 16, bottom: (_composerUnderlap ? height : 0) + 12, child: child!, ), @@ -5971,8 +6017,8 @@ class _ChatScreenState extends State child: Transform.scale( scale: 0.82 + 0.18 * t, child: SizedBox( - width: 46, - height: 46, + width: _scrollDownSize, + height: _scrollDownSize, child: Stack( clipBehavior: Clip.none, children: [ diff --git a/test/chat_bar_alignment_test.dart b/test/chat_bar_alignment_test.dart new file mode 100644 index 0000000..f9bfe62 --- /dev/null +++ b/test/chat_bar_alignment_test.dart @@ -0,0 +1,196 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/core/config/app_chat_chrome.dart'; +import 'package:komet/core/config/app_composer_background.dart'; +import 'package:komet/core/config/app_composer_style.dart'; +import 'package:komet/frontend/screens/chats/chat/upload_status.dart'; +import 'package:komet/frontend/screens/chats/chat/view/chat_header.dart'; +import 'package:komet/frontend/screens/chats/chat/video_note_controller.dart'; +import 'package:komet/frontend/screens/chats/chat/view/composer_input.dart'; +import 'package:komet/frontend/screens/chats/chat/voice_record_controller.dart'; +import 'package:komet/frontend/widgets/composer_morph_icon.dart'; +import 'package:komet/frontend/widgets/rich_message_controller.dart'; + +void main() { + late RichMessageController messageController; + late FocusNode focusNode; + late AnimationController attachAnim; + late VoiceRecordController voiceRec; + late VideoNoteController note; + late ValueNotifier replyTo; + late ValueNotifier> forwards; + late ValueNotifier hasText; + late ValueNotifier uploadStatus; + + setUp(() { + messageController = RichMessageController(); + focusNode = FocusNode(); + attachAnim = AnimationController( + vsync: const TestVSync(), + duration: const Duration(milliseconds: 200), + ); + voiceRec = VoiceRecordController( + contextOf: () => throw UnimplementedError(), + isMounted: () => true, + myId: () => 1, + onRecorded: (File file, int durationMs, List amps) async {}, + ); + note = VideoNoteController( + contextOf: () => throw UnimplementedError(), + isMounted: () => true, + onRecorded: (File file, int durationMs) async {}, + formatElapsed: (ms) => '0:00', + bottomInset: () => 0, + ); + replyTo = ValueNotifier(null); + forwards = ValueNotifier(const []); + hasText = ValueNotifier(false); + uploadStatus = ValueNotifier(const UploadStatus()); + }); + + tearDown(() { + messageController.dispose(); + focusNode.dispose(); + attachAnim.dispose(); + replyTo.dispose(); + forwards.dispose(); + hasText.dispose(); + uploadStatus.dispose(); + }); + + Future pumpBar(WidgetTester tester, ComposerStyle style) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(), + child: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: ComposerInputBar( + chatType: 'DIALOG', + chrome: ChatChromeStyle.none, + vignette: true, + style: style, + background: ComposerBackground.standard, + attachAnim: attachAnim, + replyTo: replyTo, + forwardMessages: forwards, + myId: 1, + hasText: hasText, + uploadStatus: uploadStatus, + messageController: messageController, + messageFocusNode: focusNode, + voiceRec: voiceRec, + note: note, + onToggleStickerPanel: () {}, + onSendText: () {}, + onScheduleMessage: () {}, + onOpenAttach: () {}, + onOpenAttachScheduled: () {}, + onSendHistory: (entry) async {}, + onCancelReply: () {}, + onCancelForward: () {}, + formatElapsed: (ms) => '0:00', + contextMenuBuilder: (context, state) => const SizedBox.shrink(), + isMuted: false, + onToggleMute: () {}, + ), + ), + ), + ), + ), + ); + await tester.pump(); + return tester.getSize(find.byType(ComposerInputBar)).height; + } + + Future pumpHeader(WidgetTester tester) async { + final status = ValueNotifier('online'); + final scheduled = ValueNotifier(0); + final unread = ValueNotifier(0); + addTearDown(status.dispose); + addTearDown(scheduled.dispose); + addTearDown(unread.dispose); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: SizedBox( + height: kToolbarHeight, + child: ChatHeaderRow( + glossy: false, + frosted: false, + cs: Theme.of(context).colorScheme, + embedded: false, + chatId: 0, + heroTag: 'header', + name: 'Chat', + imageUrl: '', + chatType: 'CHAT', + isOfficial: false, + myId: 1, + headerStatus: status, + scheduledCount: scheduled, + otherUnread: unread, + showCall: true, + onClose: null, + onOpenInfo: () {}, + onOpenScheduled: () {}, + onCall: () {}, + onMenu: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pump(); + } + + testWidgets('the material composer is exactly as tall as the app bar', ( + tester, + ) async { + expect(await pumpBar(tester, ComposerStyle.materialYou), kToolbarHeight); + }); + + testWidgets('the glossy composer keeps its taller pill layout', ( + tester, + ) async { + expect( + await pumpBar(tester, ComposerStyle.glossy), + greaterThan(kToolbarHeight), + ); + }); + + testWidgets('material actions sit on the app bar icon columns', ( + tester, + ) async { + await pumpHeader(tester); + final headerWidth = tester.getSize(find.byType(ChatHeaderRow)).width; + final back = tester.getCenter(find.byIcon(Symbols.arrow_back)).dx; + final call = headerWidth - tester.getCenter(find.byIcon(Symbols.call)).dx; + final menu = + headerWidth - tester.getCenter(find.byIcon(Symbols.more_vert)).dx; + + await pumpBar(tester, ComposerStyle.materialYou); + final width = tester.getSize(find.byType(ComposerInputBar)).width; + + expect(tester.getCenter(find.byIcon(Symbols.face)).dx, back); + expect(width - tester.getCenter(find.byIcon(Symbols.attachment)).dx, call); + expect(width - tester.getCenter(find.byType(ComposerMorphIcon)).dx, menu); + }); + + testWidgets('glossy action geometry is untouched', (tester) async { + await pumpBar(tester, ComposerStyle.glossy); + final width = tester.getSize(find.byType(ComposerInputBar)).width; + + expect(tester.getCenter(find.byType(ComposerMorphIcon)).dx, width - 39); + expect(tester.getCenter(find.byIcon(Symbols.face)).dx, 38); + expect(tester.getCenter(find.byIcon(Symbols.attachment)).dx, width - 100); + }); +}