сын не ест камни(

This commit is contained in:
Jganenokk
2026-08-22 21:12:04 +07:00
parent 101262897f
commit 6dc1835377
3 changed files with 399 additions and 114 deletions
@@ -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<double> lockDrag,
) {
return Positioned(
bottom: 62,
bottom: _controlSize + 8,
child: ValueListenableBuilder<double>(
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> 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: [
+61 -15
View File
@@ -581,6 +581,9 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
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<ChatScreen>
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<ChatScreen>
? 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<ChatScreen>
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<ChatScreen>
senderAvatar: _searchSenderAvatar,
),
if (vignette) ...[
Positioned(
top: 0,
left: 0,
right: 0,
child: _buildEdgeVignette(cs, top: true),
),
ValueListenableBuilder<double>(
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<double>(
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<ChatScreen>
);
}
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<ChatScreen>
return ValueListenableBuilder<double>(
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<ChatScreen>
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: [
+196
View File
@@ -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<CachedMessage?> replyTo;
late ValueNotifier<List<CachedMessage>> forwards;
late ValueNotifier<bool> hasText;
late ValueNotifier<UploadStatus> 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<double> 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<double> 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<void> pumpHeader(WidgetTester tester) async {
final status = ValueNotifier<String>('online');
final scheduled = ValueNotifier<int>(0);
final unread = ValueNotifier<int>(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);
});
}