feat: работа с текстом
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+26
-8
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user