feat: работа с текстом

This commit is contained in:
Jganenokk
2026-07-26 12:32:38 +07:00
parent 6c084860b1
commit 310e1fdb4a
34 changed files with 2309 additions and 227 deletions
@@ -0,0 +1,163 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../../backend/modules/messages.dart';
import '../../../../models/attachment.dart';
class _ControlSegment {
final String text;
final int? userId;
const _ControlSegment(this.text, [this.userId]);
}
class _ControlText {
final List<_ControlSegment> segments;
final int? tapUserId;
const _ControlText(this.segments, this.tapUserId);
}
class ControlBubble extends StatefulWidget {
final CachedMessage message;
final ColorScheme cs;
final void Function(int userId)? onUserTap;
const ControlBubble({
super.key,
required this.message,
required this.cs,
this.onUserTap,
});
@override
State<ControlBubble> createState() => _ControlBubbleState();
}
class _ControlBubbleState extends State<ControlBubble> {
final Map<int, TapGestureRecognizer> _recognizers = {};
@override
void dispose() {
for (final recognizer in _recognizers.values) {
recognizer.dispose();
}
super.dispose();
}
TapGestureRecognizer _recognizerFor(int userId) => _recognizers.putIfAbsent(
userId,
() => TapGestureRecognizer()..onTap = () => widget.onUserTap?.call(userId),
);
String _nameOf(int userId) => ContactCache.get(userId) ?? 'Пользователь';
int? _mentionedUser(ControlAttachment control) {
final direct = control.userId;
if (direct != null && direct != 0) return direct;
final ids = control.userIds;
if (ids != null && ids.length == 1) return ids.first;
return null;
}
_ControlText _resolveText(ControlAttachment control) {
final senderId = widget.message.senderId;
final sender = _ControlSegment(_nameOf(senderId), senderId);
switch (control.event) {
case 'new':
return _ControlText([
sender,
const _ControlSegment(' создал(а) чат'),
], senderId);
case 'add':
final ids = control.userIds ?? const <int>[];
final segments = <_ControlSegment>[
sender,
const _ControlSegment(' добавил(а) '),
];
for (var i = 0; i < ids.length; i++) {
if (i > 0) segments.add(const _ControlSegment(', '));
segments.add(_ControlSegment(_nameOf(ids[i]), ids[i]));
}
return _ControlText(segments, ids.length == 1 ? ids.first : null);
case 'leave':
return _ControlText([
sender,
const _ControlSegment(' покинул(а) чат'),
], senderId);
case 'joinByLink':
return _ControlText([
sender,
const _ControlSegment(' присоединился(-ась) к чату'),
], senderId);
case 'pin':
return _ControlText([
sender,
const _ControlSegment(' закрепил(а) сообщение'),
], senderId);
default:
return _ControlText([
_ControlSegment(control.title ?? ''),
], _mentionedUser(control) ?? senderId);
}
}
@override
Widget build(BuildContext context) {
final attachments = widget.message.attachments;
if (attachments == null || attachments.isEmpty) {
return const SizedBox.shrink();
}
final control = attachments.first;
if (control is! ControlAttachment) return const SizedBox.shrink();
final resolved = _resolveText(control);
if (resolved.segments.every((s) => s.text.isEmpty)) {
return const SizedBox.shrink();
}
final cs = widget.cs;
final interactive = widget.onUserTap != null;
final bubble = Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
),
child: Text.rich(
TextSpan(
children: [
for (final segment in resolved.segments)
TextSpan(
text: segment.text,
style: interactive && segment.userId != null
? const TextStyle(fontWeight: FontWeight.w600)
: null,
recognizer: interactive && segment.userId != null
? _recognizerFor(segment.userId!)
: null,
),
],
),
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
);
final tapUserId = resolved.tapUserId;
if (!interactive || tapUserId == null) return bubble;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => widget.onUserTap!(tapUserId),
child: bubble,
);
}
}
@@ -23,6 +23,7 @@ import '../../screens/chats/chat_screen.dart';
import '../custom_notification.dart';
import '../komet_avatar.dart';
import '../photo_viewer.dart';
import '../reload_on_reconnect.dart';
import '../small_spinner.dart';
import '../swipe_route.dart';
import '../video_player_screen.dart';
@@ -312,7 +313,8 @@ class CommonChatsTab extends StatefulWidget {
State<CommonChatsTab> createState() => _CommonChatsTabState();
}
class _CommonChatsTabState extends State<CommonChatsTab> {
class _CommonChatsTabState extends State<CommonChatsTab>
with ReloadOnReconnect {
bool _loading = true;
List<CommonChatEntry> _chats = const [];
Map<int, int> _onlineByChat = const {};
@@ -323,6 +325,9 @@ class _CommonChatsTabState extends State<CommonChatsTab> {
_load();
}
@override
void reloadAfterReconnect() => _load();
Future<void> _load() async {
final chats = await sharedContentModule.fetchCommonChats(widget.userId);
@@ -474,7 +479,8 @@ class SharedMediaTab extends StatefulWidget {
State<SharedMediaTab> createState() => _SharedMediaTabState();
}
class _SharedMediaTabState extends State<SharedMediaTab> {
class _SharedMediaTabState extends State<SharedMediaTab>
with ReloadOnReconnect {
static const int _pageSize = 60;
bool _loading = true;
@@ -507,6 +513,9 @@ class _SharedMediaTabState extends State<SharedMediaTab> {
}
}
@override
void reloadAfterReconnect() => _load(widget.anchorMessageId, initial: true);
Future<void> _load(String anchor, {required bool initial}) async {
final page = await sharedContentModule.fetchMedia(
chatId: widget.chatId,
+104 -53
View File
@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -26,6 +28,8 @@ void showChatMenu({
required BuildContext context,
required Rect anchorRect,
required List<ChatMenuItem> items,
Widget? header,
Widget? footer,
}) {
final overlay = Overlay.of(context, rootOverlay: true);
late OverlayEntry entry;
@@ -33,6 +37,8 @@ void showChatMenu({
builder: (ctx) => _ChatMenuLayer(
anchorRect: anchorRect,
items: items,
header: header,
footer: footer,
onDismiss: () {
if (entry.mounted) entry.remove();
},
@@ -45,25 +51,72 @@ void showChatMenu({
class _ChatMenuLayer extends StatefulWidget {
final Rect anchorRect;
final List<ChatMenuItem> items;
final Widget? header;
final Widget? footer;
final VoidCallback onDismiss;
const _ChatMenuLayer({
required this.anchorRect,
required this.items,
required this.onDismiss,
this.header,
this.footer,
});
@override
State<_ChatMenuLayer> createState() => _ChatMenuLayerState();
}
class _MenuLayout extends SingleChildLayoutDelegate {
static const double menuWidth = 290.0;
static const double margin = 8.0;
static const double gap = 6.0;
final Rect anchor;
final EdgeInsets safeArea;
const _MenuLayout({required this.anchor, required this.safeArea});
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
final width = math.min(menuWidth, constraints.maxWidth - margin * 2);
final available =
constraints.maxHeight - safeArea.top - safeArea.bottom - margin * 2;
return BoxConstraints(
minWidth: math.max(0, width),
maxWidth: math.max(0, width),
maxHeight: math.max(120.0, available),
);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
final maxLeft = math.max(margin, size.width - childSize.width - margin);
final left = (anchor.right - childSize.width).clamp(margin, maxLeft);
final topLimit = safeArea.top + margin;
final bottomLimit = size.height - safeArea.bottom - margin;
final below = anchor.bottom + gap;
final above = anchor.top - gap - childSize.height;
double top;
if (below + childSize.height <= bottomLimit) {
top = below;
} else if (above >= topLimit) {
top = above;
} else {
top = bottomLimit - childSize.height;
}
return Offset(left, math.max(topLimit, top));
}
@override
bool shouldRelayout(_MenuLayout oldDelegate) =>
oldDelegate.anchor != anchor || oldDelegate.safeArea != safeArea;
}
class _ChatMenuLayerState extends State<_ChatMenuLayer>
with SingleTickerProviderStateMixin, AnimatedOverlayPopup<_ChatMenuLayer> {
static const double _menuWidth = 290.0;
static const double _hMargin = 8.0;
static const double _vMargin = 8.0;
static const double _gap = 6.0;
@override
Duration get overlayForwardDuration => const Duration(milliseconds: 220);
@@ -78,29 +131,10 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer>
closeOverlay().then((_) => item.onTap?.call());
}
Rect _resolveRect(Size screen) {
final maxWidth = screen.width - 2 * _hMargin;
final width = maxWidth <= 0
? screen.width
: (_menuWidth.clamp(0.0, maxWidth));
final maxLeft = screen.width - width - _hMargin;
double left = widget.anchorRect.right - width;
if (left > maxLeft) left = maxLeft;
if (left < _hMargin) left = _hMargin;
final top = widget.anchorRect.bottom + _gap;
return Rect.fromLTWH(left, top, width, 0);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final screen = MediaQuery.sizeOf(context);
final bottomInset = MediaQuery.paddingOf(context).bottom;
final rect = _resolveRect(screen);
final maxHeight = (screen.height - rect.top - bottomInset - _vMargin).clamp(
120.0,
double.infinity,
);
final safeArea = MediaQuery.paddingOf(context);
return AnimatedBuilder(
animation: overlayAnimation,
builder: (ctx, child) {
@@ -115,16 +149,19 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer>
child: const SizedBox.expand(),
),
),
Positioned(
left: rect.left,
top: rect.top,
width: rect.width,
child: Opacity(
opacity: t,
child: Transform.scale(
scale: scale,
alignment: Alignment.topRight,
child: child,
Positioned.fill(
child: CustomSingleChildLayout(
delegate: _MenuLayout(
anchor: widget.anchorRect,
safeArea: safeArea,
),
child: Opacity(
opacity: t,
child: Transform.scale(
scale: scale,
alignment: Alignment.topRight,
child: child,
),
),
),
),
@@ -137,25 +174,39 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer>
clipBehavior: Clip.antiAlias,
elevation: 12,
shadowColor: Colors.black.withValues(alpha: 0.45),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 6),
for (final item in widget.items) ...[
_ChatMenuRow(item: item, onTap: () => _onItemTap(item)),
if (item.dividerAfter)
Divider(
height: 1,
thickness: 1,
color: cs.onSurface.withValues(alpha: 0.07),
),
],
const SizedBox(height: 6),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.header != null) ...[
widget.header!,
Divider(
height: 1,
thickness: 1,
color: cs.onSurface.withValues(alpha: 0.07),
),
],
),
const SizedBox(height: 6),
for (final item in widget.items) ...[
_ChatMenuRow(item: item, onTap: () => _onItemTap(item)),
if (item.dividerAfter)
Divider(
height: 1,
thickness: 1,
color: cs.onSurface.withValues(alpha: 0.07),
),
],
const SizedBox(height: 6),
if (widget.footer != null) ...[
Divider(
height: 1,
thickness: 1,
color: cs.onSurface.withValues(alpha: 0.07),
),
widget.footer!,
],
],
),
),
),
@@ -1,12 +1,18 @@
import 'package:flutter/gestures.dart';
class RightwardDragRecognizer extends HorizontalDragGestureRecognizer {
RightwardDragRecognizer({super.debugOwner}) {
class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer {
DirectionalDragRecognizer({
required this.direction,
this.minAcceptDistance = 20.0,
this.minAcceptVelocity,
super.debugOwner,
}) {
onlyAcceptDragOnThreshold = true;
}
static const double _kMinAcceptVelocity = 700.0;
static const double _kMinAcceptDistance = 20.0;
final double direction;
final double minAcceptDistance;
final double? minAcceptVelocity;
final Map<int, Offset> _initialPositions = {};
final Map<int, VelocityTracker> _velocityTrackers = {};
@@ -30,7 +36,7 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer {
);
final initial = _initialPositions[event.pointer];
if (initial != null) {
final dx = event.position.dx - initial.dx;
final dx = (event.position.dx - initial.dx) * direction;
_currentDeltaX[event.pointer] = dx;
if (dx < -kTouchSlop) {
stopTrackingPointer(event.pointer);
@@ -57,10 +63,13 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer {
for (final dx in _currentDeltaX.values) {
if (dx > maxDx) maxDx = dx;
}
if (maxDx < _kMinAcceptDistance) return false;
if (maxDx < minAcceptDistance) return false;
final minVelocity = minAcceptVelocity;
if (minVelocity == null) return true;
for (final tracker in _velocityTrackers.values) {
final vx = tracker.getVelocity().pixelsPerSecond.dx;
if (vx >= _kMinAcceptVelocity) return true;
final vx = tracker.getVelocity().pixelsPerSecond.dx * direction;
if (vx >= minVelocity) return true;
}
return false;
}
@@ -83,3 +92,12 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer {
super.rejectGesture(pointer);
}
}
class RightwardDragRecognizer extends DirectionalDragRecognizer {
RightwardDragRecognizer({super.debugOwner})
: super(direction: 1, minAcceptVelocity: 700);
}
class LeftwardDragRecognizer extends DirectionalDragRecognizer {
LeftwardDragRecognizer({super.debugOwner}) : super(direction: -1);
}
+181 -12
View File
@@ -1,16 +1,29 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../backend/modules/messages.dart' show ContactCache;
import '../../core/utils/link_opener.dart';
import '../../core/utils/text_entities.dart';
import '../../core/utils/text_format.dart';
import '../screens/contacts/open_contact_profile.dart';
import 'link_text.dart';
import 'lottie_image.dart';
import 'text_entity_actions.dart';
Color mentionTextColor(ColorScheme cs) => cs.primary;
enum TextEntityMode { menu, copy }
class FormattedMessageText extends StatefulWidget {
final String text;
final List<FormatRange> ranges;
final TextStyle style;
final TextAlign textAlign;
final TextEntityMode entityMode;
final int? maxLines;
final TextOverflow? overflow;
const FormattedMessageText({
super.key,
@@ -18,18 +31,22 @@ class FormattedMessageText extends StatefulWidget {
required this.ranges,
required this.style,
this.textAlign = TextAlign.start,
this.entityMode = TextEntityMode.menu,
this.maxLines,
this.overflow,
});
static bool isFormatted(String? text, List<FormatRange> ranges) =>
text != null &&
text.isNotEmpty &&
(ranges.isNotEmpty || LinkText.hasLinks(text));
(ranges.isNotEmpty || LinkText.hasLinks(text) || hasTextEntities(text));
static TextSpan buildInlineSpan(
String text,
List<FormatRange> ranges,
TextStyle style,
) {
TextStyle style, {
Color? mentionColor,
}) {
final quoteColor = style.color?.withValues(alpha: 0.85);
final segments = segmentizeFormats(text, ranges);
return TextSpan(
@@ -42,6 +59,7 @@ class FormattedMessageText extends StatefulWidget {
style,
segment.formats,
quoteColor: quoteColor,
mentionColor: mentionColor,
),
),
],
@@ -53,7 +71,7 @@ class FormattedMessageText extends StatefulWidget {
}
class _FormattedMessageTextState extends State<FormattedMessageText> {
final List<TapGestureRecognizer> _recognizers = [];
final List<GestureRecognizer> _recognizers = [];
@override
void dispose() {
@@ -87,13 +105,113 @@ class _FormattedMessageTextState extends State<FormattedMessageText> {
return ranges;
}
void _openMention(int userId) {
unawaited(
openContactDialogProfile(
context,
contactId: userId,
name: ContactCache.get(userId) ?? 'User #$userId',
avatarUrl: ContactCache.getAvatar(userId),
),
);
}
T _track<T extends GestureRecognizer>(T recognizer) {
_recognizers.add(recognizer);
return recognizer;
}
GestureRecognizer? _entityRecognizer(TextEntity entity) {
switch (entity.kind) {
case TextEntityKind.mention:
return _track(
TapGestureRecognizer()
..onTap = () =>
unawaited(openMentionProfile(context, entity.value)),
);
case TextEntityKind.phone:
if (widget.entityMode == TextEntityMode.copy) {
return _track(
TapGestureRecognizer()
..onTap = () => unawaited(
copyTextEntity(context, entity.value, 'Номер скопирован'),
),
);
}
return _track(
LongPressGestureRecognizer()
..onLongPressStart = (details) => showPhoneEntityMenu(
context,
entity.value,
at: details.globalPosition,
),
);
case TextEntityKind.card:
if (widget.entityMode == TextEntityMode.copy) {
return _track(
TapGestureRecognizer()
..onTap = () => unawaited(
copyTextEntity(context, entity.value, 'Номер карты скопирован'),
),
);
}
return _track(
LongPressGestureRecognizer()
..onLongPressStart = (details) => showCardEntityMenu(
context,
entity.value,
at: details.globalPosition,
),
);
}
}
List<TextSpanRange> _claimedRanges(List<FormatRange> ranges) => [
for (final range in ranges)
if (range.format == TextFormat.link ||
range.format == TextFormat.userMention)
(start: range.start, end: range.end),
];
TextEntity? _entityAt(List<TextEntity> entities, int start, int end) {
for (final entity in entities) {
if (entity.start <= start && entity.end >= end) return entity;
}
return null;
}
List<({int start, int end})> _splitByEntities(
int start,
int end,
List<TextEntity> entities,
) {
final points = <int>{start, end};
for (final entity in entities) {
if (entity.end <= start || entity.start >= end) continue;
if (entity.start > start) points.add(entity.start);
if (entity.end < end) points.add(entity.end);
}
final sorted = points.toList()..sort();
return [
for (var i = 0; i < sorted.length - 1; i++)
(start: sorted[i], end: sorted[i + 1]),
];
}
@override
Widget build(BuildContext context) {
_disposeRecognizers();
final segments = segmentizeFormats(widget.text, _withAutoLinks());
final baseColor = widget.style.color ?? Theme.of(context).colorScheme.onSurface;
final ranges = _withAutoLinks();
final entities = detectTextEntities(
widget.text,
skip: _claimedRanges(ranges),
);
final segments = segmentizeFormats(widget.text, ranges);
final cs = Theme.of(context).colorScheme;
final baseColor = widget.style.color ?? cs.onSurface;
final barColor = baseColor.withValues(alpha: 0.4);
final quoteColor = baseColor.withValues(alpha: 0.85);
final mentionColor = mentionTextColor(cs);
final spans = <InlineSpan>[];
var prevQuote = false;
@@ -121,6 +239,7 @@ class _FormattedMessageTextState extends State<FormattedMessageText> {
widget.style,
segment.formats,
quoteColor: quoteColor,
mentionColor: mentionColor,
);
final content = widget.text.substring(segment.start, segment.end);
if (segment.animojiUrl != null) {
@@ -153,22 +272,72 @@ class _FormattedMessageTextState extends State<FormattedMessageText> {
);
continue;
}
final mentionId = segment.mentionId;
if (mentionId != null && mentionId != 0) {
spans.add(
TextSpan(
text: content,
style: style,
recognizer: _track(
TapGestureRecognizer()..onTap = () => _openMention(mentionId),
),
),
);
continue;
}
final mentionName = segment.mentionName;
if (mentionName != null) {
spans.add(
TextSpan(
text: content,
style: style,
recognizer: _track(
TapGestureRecognizer()
..onTap = () =>
unawaited(openMentionProfile(context, mentionName)),
),
),
);
continue;
}
if (segment.url != null) {
final url = segment.url!;
final recognizer = TapGestureRecognizer()
..onTap = () => openExternalUrl(context, url);
_recognizers.add(recognizer);
spans.add(
TextSpan(text: content, style: style, recognizer: recognizer),
TextSpan(
text: content,
style: style,
recognizer: _track(
TapGestureRecognizer()
..onTap = () => openExternalUrl(context, url),
),
),
);
continue;
}
for (final piece in _splitByEntities(
segment.start,
segment.end,
entities,
)) {
final entity = _entityAt(entities, piece.start, piece.end);
spans.add(
TextSpan(
text: widget.text.substring(piece.start, piece.end),
style: entity == null ? style : style.copyWith(color: mentionColor),
recognizer: entity == null ? null : _entityRecognizer(entity),
),
);
} else {
spans.add(TextSpan(text: content, style: style));
}
}
return Text.rich(
TextSpan(style: widget.style, children: spans),
textAlign: widget.textAlign,
maxLines: widget.maxLines,
overflow: widget.overflow ?? TextOverflow.clip,
);
}
}
@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import '../screens/chats/chat/mention_panel_controller.dart';
import 'komet_avatar.dart';
import 'small_spinner.dart';
class MentionSuggestionsPanel extends StatefulWidget {
final List<MentionCandidate> candidates;
final double maxHeight;
final bool loadingMore;
final ValueChanged<MentionCandidate> onSelected;
final VoidCallback onLoadMore;
const MentionSuggestionsPanel({
super.key,
required this.candidates,
required this.onSelected,
required this.onLoadMore,
this.loadingMore = false,
this.maxHeight = 220,
});
@override
State<MentionSuggestionsPanel> createState() =>
_MentionSuggestionsPanelState();
}
class _MentionSuggestionsPanelState extends State<MentionSuggestionsPanel> {
final ScrollController _controller = ScrollController();
@override
void initState() {
super.initState();
_controller.addListener(_onScroll);
}
@override
void dispose() {
_controller.removeListener(_onScroll);
_controller.dispose();
super.dispose();
}
void _onScroll() {
if (!_controller.hasClients) return;
final position = _controller.position;
if (position.pixels >= position.maxScrollExtent - 120) {
widget.onLoadMore();
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final candidates = widget.candidates;
return Material(
type: MaterialType.transparency,
child: Container(
constraints: BoxConstraints(maxHeight: widget.maxHeight),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
),
clipBehavior: Clip.antiAlias,
child: candidates.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: Center(
child: SmallSpinner(size: 20, color: cs.onSurfaceVariant),
),
)
: ListView.separated(
controller: _controller,
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 6),
itemCount: candidates.length + (widget.loadingMore ? 1 : 0),
separatorBuilder: (_, _) => Divider(
height: 1,
thickness: 1,
indent: 14,
endIndent: 14,
color: cs.outlineVariant.withValues(alpha: 0.18),
),
itemBuilder: (context, i) {
if (i >= candidates.length) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Center(
child: SmallSpinner(
size: 18,
color: cs.onSurfaceVariant,
),
),
);
}
final candidate = candidates[i];
return InkWell(
onTap: () => widget.onSelected(candidate),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
child: Row(
children: [
KometAvatar(
name: candidate.name,
imageUrl: candidate.avatarUrl,
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Text(
candidate.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
),
),
),
],
),
),
);
},
),
),
);
}
}
+67 -104
View File
@@ -27,6 +27,7 @@ import 'attachment/bubbles/bubble_context.dart';
import 'attachment/bubbles/poll_bubble.dart';
import 'attachment/bubbles/share_bubble.dart';
import 'attachment/bubbles/call_bubble.dart';
import 'attachment/bubbles/control_bubble.dart';
import 'attachment/bubbles/location_bubble.dart';
import 'attachment/bubbles/contact_bubble.dart';
import 'attachment/bubbles/sticker_bubble.dart';
@@ -282,6 +283,14 @@ class MessageBubble extends StatelessWidget {
return photoCount >= 2 && !hasCaption;
}
bool get _showsSenderName =>
!isMe &&
chatType == "CHAT" &&
prevMessage?.senderId != message.senderId;
bool get _stretchesTextRow =>
message.replyInfo != null || _showsSenderName;
BubbleShape _computeShape() {
if (message.isControl) return BubbleShape.singleMiddle;
@@ -589,10 +598,7 @@ class MessageBubble extends StatelessWidget {
showAvatarSlot &&
chatType == "CHAT" &&
nextMessage?.senderId != message.senderId;
final showSenderName =
showAvatarSlot &&
chatType == "CHAT" &&
prevMessage?.senderId != message.senderId;
final showSenderName = _showsSenderName;
final maxBubbleWidth = math.min(MediaQuery.sizeOf(context).width * 0.75, 560.0);
final keyboard = _inlineKeyboard;
@@ -635,51 +641,60 @@ class MessageBubble extends StatelessWidget {
final reactionsInside = contentType != MessageType.text && !reactionsUnder;
final reply = message.replyInfo;
Widget withReply(Widget content) {
if (reply == null) return content;
final quote = _buildReplyQuote(context, cs, textColor, reply);
if (contentType != MessageType.text || jumboAnimoji != null) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [quote, const SizedBox(height: 4), content],
);
}
return IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ZeroIntrinsicWidth(child: quote),
const SizedBox(height: 4),
content,
],
),
);
}
final bool hasCommentsFooter = onCommentsTap != null;
final EdgeInsets containerPadding = hasCommentsFooter
? EdgeInsets.zero
: padding;
final Widget innerContent = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showSenderName)
_buildSenderHeader(cs, padding == EdgeInsets.zero),
withReply(
reactionsInside
? Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [bubbleContent, _reactionsBar(cs)],
)
: bubbleContent,
),
],
);
final Widget contentWithReactions = reactionsInside
? Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [bubbleContent, _reactionsBar(cs)],
)
: bubbleContent;
final Widget? senderHeader = showSenderName
? _buildSenderHeader(cs, padding == EdgeInsets.zero)
: null;
final Widget innerContent =
contentType == MessageType.text &&
jumboAnimoji == null &&
_stretchesTextRow
? IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (senderHeader != null)
Align(
alignment: AlignmentDirectional.centerStart,
child: senderHeader,
),
if (reply != null) ...[
_ZeroIntrinsicWidth(
child: _buildReplyQuote(context, cs, textColor, reply),
),
const SizedBox(height: 4),
],
contentWithReactions,
],
),
)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
?senderHeader,
if (reply != null) ...[
_buildReplyQuote(context, cs, textColor, reply),
const SizedBox(height: 4),
],
contentWithReactions,
],
);
final Widget bubbleBox = ListenableBuilder(
listenable: Listenable.merge([
@@ -1199,66 +1214,12 @@ class MessageBubble extends StatelessWidget {
);
}
Widget _buildControlContent(ColorScheme cs) {
final attachments = message.attachments;
if (attachments == null || attachments.isEmpty) {
return const SizedBox.shrink();
}
final control = attachments.first;
if (control is! ControlAttachment) return const SizedBox.shrink();
String? text;
switch (control.event) {
case 'system':
text = control.title;
break;
case 'new':
text =
'${ContactCache.get(message.senderId) ?? 'Пользователь'} создал(а) чат';
break;
case 'add':
final names = (control.userIds ?? [])
.map((id) => ContactCache.get(id) ?? 'Пользователь')
.join(', ');
text =
'${ContactCache.get(message.senderId) ?? 'Пользователь'} добавил(а) $names';
break;
case 'leave':
text =
'${ContactCache.get(message.senderId) ?? 'Пользователь'} покинул(а) чат';
break;
case 'joinByLink':
text =
'${ContactCache.get(message.senderId) ?? 'Пользователь'} присоединился(-ась) к чату';
break;
case 'pin':
text =
'${ContactCache.get(message.senderId) ?? 'Пользователь'} закрепил(а) сообщение';
break;
default:
text = control.title;
}
if (text == null || text.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
),
child: Text(
text,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
);
}
Widget _buildControlContent(ColorScheme cs) => ControlBubble(
key: ValueKey('control_${message.id}'),
message: message,
cs: cs,
onUserTap: onAvatarTap,
);
Widget _wrapSelectable(Widget textWidget) {
final listenable = textSelection;
@@ -1366,7 +1327,9 @@ class MessageBubble extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(child: textWidget),
_stretchesTextRow
? Expanded(child: textWidget)
: Flexible(child: textWidget),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 2),
+36 -12
View File
@@ -121,7 +121,10 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
void initState() {
super.initState();
_items = _localItems();
_index = widget.initialIndex.clamp(0, _items.length - 1);
_index = (_items.length - 1 - widget.initialIndex).clamp(
0,
_items.length - 1,
);
_controller = PageController(initialPage: _index);
unawaited(_loadFeed());
}
@@ -135,7 +138,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
List<_ViewerPhoto> _localItems() {
final message = widget.message;
return [
for (var i = 0; i < widget.photos.length; i++)
for (var i = widget.photos.length - 1; i >= 0; i--)
_ViewerPhoto(
id: _localId(widget.photos[i], message, i),
photo: widget.photos[i],
@@ -147,6 +150,23 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
];
}
List<_ViewerPhoto> _feedItems(List<SharedMediaItem> items) {
final out = <_ViewerPhoto>[];
var start = 0;
while (start < items.length) {
var end = start;
while (end + 1 < items.length &&
items[end + 1].messageId == items[start].messageId) {
end++;
}
for (var i = end; i >= start; i--) {
out.add(_ViewerPhoto.fromFeed(items[i]));
}
start = end + 1;
}
return out;
}
String _localId(PhotoAttachment photo, CachedMessage? message, int at) {
final key = _feedKey(photo, message);
return key ?? 'local:${message?.id ?? ''}:$at';
@@ -182,7 +202,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
return;
}
final items = feed.items.map(_ViewerPhoto.fromFeed).toList();
final items = _feedItems(feed.items);
final at = items.indexWhere((i) => i.id == key);
if (at == -1) {
setState(() => _feedFailed = true);
@@ -224,7 +244,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
);
if (!mounted) return;
final items = feed.items.map(_ViewerPhoto.fromFeed).toList();
final items = _feedItems(feed.items);
final at = items.indexWhere((i) => i.id == _current.id);
if (at == -1) {
setState(() {
@@ -413,8 +433,8 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
backgroundColor: Colors.black,
body: CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _step(-1),
const SingleActivator(LogicalKeyboardKey.arrowRight): () => _step(1),
const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _step(1),
const SingleActivator(LogicalKeyboardKey.arrowRight): () => _step(-1),
},
child: Focus(
autofocus: true,
@@ -424,6 +444,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
child: PageView.builder(
key: ValueKey(_pager),
controller: _controller,
reverse: true,
itemCount: _items.length,
onPageChanged: _onPageChanged,
itemBuilder: (_, i) => GestureDetector(
@@ -451,15 +472,18 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
curve: Curves.easeOut,
child: Stack(
children: [
if (_index > 0)
Align(
alignment: Alignment.centerLeft,
child: _arrow(Symbols.chevron_left, () => _step(-1)),
),
if (_index < _items.length - 1)
Align(
alignment: Alignment.centerLeft,
child: _arrow(Symbols.chevron_left, () => _step(1)),
),
if (_index > 0)
Align(
alignment: Alignment.centerRight,
child: _arrow(Symbols.chevron_right, () => _step(1)),
child: _arrow(
Symbols.chevron_right,
() => _step(-1),
),
),
Positioned(
top: padding.top + 8,
@@ -0,0 +1,32 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import '../../backend/api.dart';
import '../../main.dart' show api;
mixin ReloadOnReconnect<T extends StatefulWidget> on State<T> {
StreamSubscription<SessionState>? _reconnectSub;
int _reloadedEpoch = api.sessionEpoch;
void reloadAfterReconnect();
@override
void initState() {
super.initState();
_reconnectSub = api.stateStream.listen(_onSessionState);
}
@override
void dispose() {
_reconnectSub?.cancel();
super.dispose();
}
void _onSessionState(SessionState state) {
if (state != SessionState.online) return;
if (api.sessionEpoch == _reloadedEpoch) return;
_reloadedEpoch = api.sessionEpoch;
if (mounted) reloadAfterReconnect();
}
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../core/utils/text_format.dart';
import '../../models/animoji.dart';
import 'formatted_message_text.dart';
import 'lottie_image.dart';
const List<TextFormat> composerFormats = [
@@ -18,6 +19,13 @@ class _Interval {
_Interval(this.start, this.end);
}
class _MentionEntity {
int start;
int end;
final int userId;
_MentionEntity(this.start, this.end, this.userId);
}
class _AnimojiEntity {
final int uid;
int offset;
@@ -39,6 +47,7 @@ class RichMessageController extends TextEditingController {
final Map<TextFormat, List<_Interval>> _intervals = {};
final List<_AnimojiEntity> _animoji = [];
final List<_MentionEntity> _mentions = [];
int _entitySeq = 0;
RichMessageController({super.text});
@@ -73,6 +82,27 @@ class RichMessageController extends TextEditingController {
notifyListeners();
}
void insertMention({
required int userId,
required String name,
required int start,
required int end,
}) {
final oldText = value.text;
if (start < 0 || end > oldText.length || start > end || name.isEmpty) {
return;
}
final inserted = '$name ';
value = TextEditingValue(
text: oldText.replaceRange(start, end, inserted),
selection: TextSelection.collapsed(offset: start + inserted.length),
);
_mentions.add(_MentionEntity(start, start + name.length, userId));
_mentions.sort((a, b) => a.start.compareTo(b.start));
notifyListeners();
}
({String text, List<Map<String, dynamic>> elements}) buildContent() {
final src = value.text;
if (_animoji.isEmpty) {
@@ -121,6 +151,7 @@ class RichMessageController extends TextEditingController {
'type': textFormatToServer(range.format),
'from': from,
'length': to - from,
if (range.entityId != null) 'entityId': range.entityId,
});
}
return (text: glyphText, elements: elements);
@@ -136,7 +167,8 @@ class RichMessageController extends TextEditingController {
super.value = newValue;
}
bool get hasFormatting => _intervals.values.any((list) => list.isNotEmpty);
bool get hasFormatting =>
_intervals.values.any((list) => list.isNotEmpty) || _mentions.isNotEmpty;
void clearFormatting() {
if (_intervals.isEmpty) return;
@@ -146,12 +178,21 @@ class RichMessageController extends TextEditingController {
void setFormatRanges(Iterable<FormatRange> ranges) {
_intervals.clear();
_mentions.clear();
for (final range in ranges) {
if (range.format == TextFormat.userMention) {
final userId = range.entityId;
if (userId != null) {
_mentions.add(_MentionEntity(range.start, range.end, userId));
}
continue;
}
if (!composerFormats.contains(range.format)) continue;
_intervals
.putIfAbsent(range.format, () => [])
.add(_Interval(range.start, range.end));
}
_mentions.sort((a, b) => a.start.compareTo(b.start));
for (final list in _intervals.values) {
_normalize(list);
}
@@ -175,6 +216,16 @@ class RichMessageController extends TextEditingController {
);
}
});
for (final mention in _mentions) {
ranges.add(
FormatRange(
format: TextFormat.userMention,
start: mention.start,
length: mention.end - mention.start,
entityId: mention.userId,
),
);
}
return ranges;
}
@@ -200,7 +251,7 @@ class RichMessageController extends TextEditingController {
}
void _remap(String oldText, String newText) {
if (_intervals.isEmpty && _animoji.isEmpty) return;
if (_intervals.isEmpty && _animoji.isEmpty && _mentions.isEmpty) return;
final oldLen = oldText.length;
final newLen = newText.length;
@@ -240,6 +291,16 @@ class RichMessageController extends TextEditingController {
}
}
if (_mentions.isNotEmpty) {
_mentions.removeWhere(
(mention) => changeStart < mention.end && oldChangeEnd > mention.start,
);
for (final mention in _mentions) {
mention.start = mapStart(mention.start);
mention.end = mapEnd(mention.end);
}
}
final empty = <TextFormat>[];
_intervals.forEach((format, list) {
for (final interval in list) {
@@ -325,6 +386,7 @@ class RichMessageController extends TextEditingController {
final ranges = _toFormatRanges();
final baseColor = baseStyle.color;
final quoteColor = baseColor?.withValues(alpha: 0.85);
final mentionColor = mentionTextColor(Theme.of(context).colorScheme);
final segments = segmentizeFormats(content, ranges);
final entityByOffset = {for (final e in _animoji) e.offset: e};
final box = (baseStyle.fontSize ?? 16) * 1.4;
@@ -335,6 +397,7 @@ class RichMessageController extends TextEditingController {
baseStyle,
segment.formats,
quoteColor: quoteColor,
mentionColor: mentionColor,
);
var runStart = segment.start;
var i = segment.start;
+1 -1
View File
@@ -3,7 +3,7 @@ import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'rightward_drag_recognizer.dart';
import 'directional_drag_recognizer.dart';
class SwipeRoute<T> extends PageRoute<T> {
SwipeRoute({
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'rightward_drag_recognizer.dart';
import 'directional_drag_recognizer.dart';
class SwipeToPop extends StatefulWidget {
final Widget child;
@@ -0,0 +1,210 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../backend/modules/contacts.dart';
import '../../core/utils/text_entities.dart';
import '../../main.dart' show api;
import 'chat_menu_overlay.dart';
import 'custom_notification.dart';
import 'komet_avatar.dart';
import 'max_link_handler.dart';
import 'small_spinner.dart';
Future<void> openMentionProfile(BuildContext context, String nickname) async {
final handled = await tryHandleMaxLink(context, 'https://max.ru/$nickname');
if (handled || !context.mounted) return;
showCustomNotification(context, 'Профиль @$nickname не найден');
}
Future<void> copyTextEntity(
BuildContext context,
String value,
String message,
) async {
await Clipboard.setData(ClipboardData(text: value));
if (!context.mounted) return;
showCustomNotification(context, message);
}
void showPhoneEntityMenu(
BuildContext context,
String phone, {
required Offset at,
}) {
showChatMenu(
context: context,
anchorRect: Rect.fromLTWH(at.dx, at.dy, 0, 0),
header: _PhoneOwnerHeader(phone: phone),
items: [
ChatMenuItem(
icon: Symbols.content_copy,
label: 'Скопировать номер телефона',
onTap: () => copyTextEntity(context, phone, 'Номер скопирован'),
),
if (defaultTargetPlatform == TargetPlatform.android)
ChatMenuItem(
icon: Symbols.call,
label: 'Позвонить',
onTap: () => _dial(context, phone),
),
],
);
}
void showCardEntityMenu(
BuildContext context,
String digits, {
required Offset at,
}) {
showChatMenu(
context: context,
anchorRect: Rect.fromLTWH(at.dx, at.dy, 0, 0),
items: [
ChatMenuItem(
icon: Symbols.content_copy,
label: 'Скопировать номер карты',
onTap: () => copyTextEntity(context, digits, 'Номер карты скопирован'),
),
],
footer: _CardFooter(digits: digits),
);
}
Future<void> _dial(BuildContext context, String phone) async {
final uri = Uri(scheme: 'tel', path: phone);
var launched = false;
try {
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
launched = false;
}
if (launched || !context.mounted) return;
showCustomNotification(context, 'Не удалось открыть приложение звонков');
}
class _CardFooter extends StatelessWidget {
final String digits;
const _CardFooter({required this.digits});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final title = cardBrandTitle(digits);
return Padding(
padding: const EdgeInsets.fromLTRB(18, 12, 18, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
cardMask(digits),
style: TextStyle(color: cs.onSurface, fontSize: 15),
),
if (title != null) ...[
const SizedBox(height: 2),
Text(
title,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
],
],
),
);
}
}
class _PhoneOwnerHeader extends StatefulWidget {
final String phone;
const _PhoneOwnerHeader({required this.phone});
@override
State<_PhoneOwnerHeader> createState() => _PhoneOwnerHeaderState();
}
class _PhoneOwnerHeaderState extends State<_PhoneOwnerHeader> {
late final Future<PhoneLookupResult?> _lookup = ContactsModule.findByPhone(
api,
widget.phone,
silent: true,
);
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return FutureBuilder<PhoneLookupResult?>(
future: _lookup,
builder: (context, snapshot) {
final Widget content;
if (snapshot.connectionState != ConnectionState.done) {
content = Row(
children: [
SmallSpinner(size: 18, color: cs.onSurfaceVariant),
const SizedBox(width: 12),
Text(
widget.phone,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
],
);
} else {
final found = snapshot.data;
content = found == null
? Text(
'Человека ещё нет в MAX',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
)
: _OwnerRow(found: found, phone: widget.phone);
}
return Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 12),
child: content,
);
},
);
}
}
class _OwnerRow extends StatelessWidget {
final PhoneLookupResult found;
final String phone;
const _OwnerRow({required this.found, required this.phone});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final resolved = found.name;
final name = (resolved == null || resolved.isEmpty) ? phone : resolved;
return Row(
children: [
KometAvatar(name: name, size: 36, imageUrl: found.avatarUrl),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
Text(
phone,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
],
),
),
],
);
}
}