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
+6
View File
@@ -307,18 +307,22 @@ class ChatSearchHit {
class ChatMemberEntry {
final int id;
final String? name;
final String? fullName;
final String? avatarUrl;
final int? seenTime;
final int presenceStatus;
final bool blocked;
final bool isContact;
const ChatMemberEntry({
required this.id,
this.name,
this.fullName,
this.avatarUrl,
this.seenTime,
required this.presenceStatus,
this.blocked = false,
this.isContact = false,
});
bool get isOnline => presenceStatus == 1;
@@ -1865,10 +1869,12 @@ class ChatsModule {
ChatMemberEntry(
id: id,
name: name,
fullName: info.fullName,
avatarUrl: avatar,
seenTime: seen,
presenceStatus: status,
blocked: info.isDeleted,
isContact: info.isSavedContact,
),
);
}
+13 -4
View File
@@ -94,12 +94,21 @@ class AddContactResult {
class ContactsModule {
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
static Future<PhoneLookupResult?> findByPhone(Api api, String phone) async {
static Future<PhoneLookupResult?> findByPhone(
Api api,
String phone, {
bool silent = false,
}) async {
final normalized = _normalizePhone(phone);
if (normalized == null) return null;
final packet = await api.sendRequest(Opcode.contactInfoByPhone, {
'phone': normalized,
});
final Packet packet;
try {
packet = await api.sendRequest(Opcode.contactInfoByPhone, {
'phone': normalized,
}, silent: silent);
} on PacketError {
return null;
}
if (packet.isError) return null;
final contact = (packet.payload as Map?)?['contact'];
if (contact is! Map) return null;
+3 -1
View File
@@ -31,7 +31,9 @@ abstract class LinkModule {
static Future<ResolvedLink?> resolve(Api api, String url) async {
final Packet response;
try {
response = await api.sendRequest(Opcode.linkInfo, {'link': url});
response = await api.sendRequest(Opcode.linkInfo, {
'link': url,
}, silent: true);
} on TimeoutException {
return const ResolvedLinkError('Превышено время ожидания');
} on PacketError catch (e) {
+179
View File
@@ -0,0 +1,179 @@
enum TextEntityKind { mention, phone, card }
class TextEntity {
final TextEntityKind kind;
final int start;
final int end;
final String value;
const TextEntity({
required this.kind,
required this.start,
required this.end,
required this.value,
});
int get length => end - start;
}
typedef TextSpanRange = ({int start, int end});
final RegExp _cardPattern = RegExp(
r'(?<![\d.,])\d(?:[ -]?\d){12,18}(?![\d.,])',
);
final RegExp _phonePattern = RegExp(
r'(?<![\d.,])(?:\+\d(?:[ ()-]{0,3}\d){9,14}|[78](?:[ ()-]{0,3}\d){10})(?![\d.,])',
);
final RegExp _mentionPattern = RegExp(r'(?<![\w@/])@([A-Za-z0-9_]{2,32})');
const Map<String, String> _cardBrands = {
'MIR': 'МИР',
'VISA': 'Visa',
'MASTERCARD': 'Mastercard',
'MAESTRO': 'Maestro',
'AMEX': 'American Express',
'UNIONPAY': 'UnionPay',
'JCB': 'JCB',
'DINERS': 'Diners Club',
'DISCOVER': 'Discover',
};
String? cardBrand(String digits) {
if (digits.length < 13) return null;
int prefix(int length) => int.parse(digits.substring(0, length));
final p1 = prefix(1);
final p2 = prefix(2);
final p3 = prefix(3);
final p4 = prefix(4);
final p6 = digits.length >= 6 ? prefix(6) : 0;
if (p4 >= 2200 && p4 <= 2204) return 'MIR';
if (p1 == 4) return 'VISA';
if (p2 >= 51 && p2 <= 55) return 'MASTERCARD';
if (p4 >= 2221 && p4 <= 2720) return 'MASTERCARD';
if (p2 == 34 || p2 == 37) return 'AMEX';
if (p2 == 62) return 'UNIONPAY';
if (p4 >= 3528 && p4 <= 3589) return 'JCB';
if (p3 >= 300 && p3 <= 305) return 'DINERS';
if (p2 == 36 || p2 == 38 || p2 == 39) return 'DINERS';
if (p4 == 6011 || p2 == 65) return 'DISCOVER';
if (p3 >= 644 && p3 <= 649) return 'DISCOVER';
if (p6 >= 622126 && p6 <= 622925) return 'DISCOVER';
if (p4 == 5018 || p4 == 5020 || p4 == 5038 || p4 == 6304) return 'MAESTRO';
if (p4 == 6759 || (p4 >= 6761 && p4 <= 6763)) return 'MAESTRO';
return null;
}
String? cardBrandTitle(String digits) {
final brand = cardBrand(digits);
return brand == null ? null : _cardBrands[brand];
}
String cardMask(String digits) {
final brand = cardBrand(digits) ?? 'CARD';
final tail = digits.length >= 4
? digits.substring(digits.length - 4)
: digits;
return '$brand*$tail';
}
String formatCardNumber(String digits) {
final buffer = StringBuffer();
for (var i = 0; i < digits.length; i++) {
if (i > 0 && i % 4 == 0) buffer.write(' ');
buffer.write(digits[i]);
}
return buffer.toString();
}
bool isLuhnValid(String digits) {
if (digits.length < 12) return false;
var sum = 0;
var double = false;
for (var i = digits.length - 1; i >= 0; i--) {
var value = digits.codeUnitAt(i) - 0x30;
if (value < 0 || value > 9) return false;
if (double) {
value *= 2;
if (value > 9) value -= 9;
}
sum += value;
double = !double;
}
return sum % 10 == 0;
}
String _digitsOf(String raw) {
final buffer = StringBuffer();
for (var i = 0; i < raw.length; i++) {
final code = raw.codeUnitAt(i);
if (code >= 0x30 && code <= 0x39) buffer.writeCharCode(code);
}
return buffer.toString();
}
bool _mayContainEntities(String text) {
for (var i = 0; i < text.length; i++) {
final code = text.codeUnitAt(i);
if (code == 0x40) return true;
if (code >= 0x30 && code <= 0x39) return true;
}
return false;
}
List<TextEntity> detectTextEntities(
String text, {
Iterable<TextSpanRange> skip = const [],
}) {
if (text.isEmpty || !_mayContainEntities(text)) return const [];
final taken = <TextSpanRange>[...skip];
bool free(int start, int end) =>
!taken.any((r) => start < r.end && end > r.start);
final found = <TextEntity>[];
void collect(
RegExp pattern,
TextEntityKind kind,
String? Function(RegExpMatch match) valueOf,
) {
for (final match in pattern.allMatches(text)) {
if (!free(match.start, match.end)) continue;
final value = valueOf(match);
if (value == null) continue;
taken.add((start: match.start, end: match.end));
found.add(
TextEntity(
kind: kind,
start: match.start,
end: match.end,
value: value,
),
);
}
}
collect(_cardPattern, TextEntityKind.card, (match) {
final digits = _digitsOf(match.group(0)!);
if (digits.length < 13 || digits.length > 19) return null;
if (cardBrand(digits) == null) return null;
if (!isLuhnValid(digits)) return null;
return digits;
});
collect(_phonePattern, TextEntityKind.phone, (match) {
final digits = _digitsOf(match.group(0)!);
if (digits.length < 10 || digits.length > 15) return null;
return '+$digits';
});
collect(_mentionPattern, TextEntityKind.mention, (match) => match.group(1));
found.sort((a, b) => a.start.compareTo(b.start));
return found;
}
bool hasTextEntities(String text, {Iterable<TextSpanRange> skip = const []}) =>
detectTextEntities(text, skip: skip).isNotEmpty;
+34 -1
View File
@@ -9,6 +9,7 @@ enum TextFormat {
quote,
link,
animoji,
userMention,
}
const Map<TextFormat, String> _formatToServer = {
@@ -20,6 +21,7 @@ const Map<TextFormat, String> _formatToServer = {
TextFormat.quote: 'QUOTE',
TextFormat.link: 'LINK',
TextFormat.animoji: 'ANIMOJI',
TextFormat.userMention: 'USER_MENTION',
};
final Map<String, TextFormat> _serverToFormat = {
@@ -35,12 +37,16 @@ class FormatRange {
final TextFormat format;
final int start;
final int length;
final int? entityId;
final String? entityName;
final Map<String, dynamic>? attributes;
const FormatRange({
required this.format,
required this.start,
required this.length,
this.entityId,
this.entityName,
this.attributes,
});
@@ -60,6 +66,8 @@ class FormatRange {
'type': textFormatToServer(format),
'from': start,
'length': length,
if (entityId != null) 'entityId': entityId,
if (entityName != null) 'entityName': entityName,
if (attributes != null) 'attributes': attributes,
};
}
@@ -78,11 +86,17 @@ List<FormatRange> parseFormatElements(dynamic raw) {
final attributes = attrsRaw is Map
? Map<String, dynamic>.from(attrsRaw)
: null;
final entityId = item['entityId'];
final entityName = item['entityName'];
result.add(
FormatRange(
format: format,
start: from,
length: length,
entityId: entityId is int ? entityId : null,
entityName: entityName is String && entityName.isNotEmpty
? entityName
: null,
attributes: attributes,
),
);
@@ -134,6 +148,8 @@ class FormatSegment {
final Set<TextFormat> formats;
final String? url;
final String? animojiUrl;
final int? mentionId;
final String? mentionName;
const FormatSegment({
required this.start,
@@ -141,6 +157,8 @@ class FormatSegment {
required this.formats,
this.url,
this.animojiUrl,
this.mentionId,
this.mentionName,
});
}
@@ -157,6 +175,8 @@ List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
format: range.format,
start: start,
length: end - start,
entityId: range.entityId,
entityName: range.entityName,
attributes: range.attributes,
),
);
@@ -180,11 +200,17 @@ List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
final formats = <TextFormat>{};
String? url;
String? animojiUrl;
int? mentionId;
String? mentionName;
for (final range in clamped) {
if (range.start <= start && range.end >= end) {
formats.add(range.format);
if (range.format == TextFormat.link) url ??= range.url;
if (range.format == TextFormat.animoji) animojiUrl ??= range.animojiUrl;
if (range.format == TextFormat.userMention) {
mentionId ??= range.entityId;
mentionName ??= range.entityName;
}
}
}
segments.add(
@@ -194,6 +220,8 @@ List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
formats: formats,
url: url,
animojiUrl: animojiUrl,
mentionId: mentionId,
mentionName: mentionName,
),
);
}
@@ -204,6 +232,7 @@ TextStyle applyTextFormats(
TextStyle base,
Set<TextFormat> formats, {
Color? quoteColor,
Color? mentionColor,
}) {
if (formats.isEmpty) return base;
@@ -219,11 +248,15 @@ TextStyle applyTextFormats(
final isItalic = formats.contains(TextFormat.emphasized) ||
formats.contains(TextFormat.quote);
final isMention = formats.contains(TextFormat.userMention);
return base.copyWith(
fontWeight: formats.contains(TextFormat.strong) ? FontWeight.w700 : null,
fontStyle: isItalic ? FontStyle.italic : null,
fontFamily: formats.contains(TextFormat.monospaced) ? 'monospace' : null,
color: formats.contains(TextFormat.quote) ? quoteColor : null,
color: isMention
? mentionColor
: (formats.contains(TextFormat.quote) ? quoteColor : null),
decoration: decorations.isEmpty
? null
: TextDecoration.combine(decorations),
+7 -1
View File
@@ -10,6 +10,7 @@ import '../../../core/calls/call_controller.dart';
import '../../../backend/modules/calls.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/chat_menu_overlay.dart';
import '../../widgets/small_spinner.dart';
@@ -22,7 +23,7 @@ class CallsTab extends StatefulWidget {
State<CallsTab> createState() => _CallsTabState();
}
class _CallsTabState extends State<CallsTab> {
class _CallsTabState extends State<CallsTab> with ReloadOnReconnect {
List<CallLogEntry> _calls = [];
final Set<String> _removing = {};
bool _isLoading = true;
@@ -51,6 +52,11 @@ class _CallsTabState extends State<CallsTab> {
super.dispose();
}
@override
void reloadAfterReconnect() {
if (accountModule.isLoggedIn) _loadHistory();
}
Future<void> _loadHistory() async {
final p = await AppDatabase.loadActiveProfile();
if (p == null) {
@@ -0,0 +1,229 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../../../backend/modules/chats.dart' show chats;
import '../../../../main.dart' show api;
class MentionCandidate {
final int id;
final String name;
final String? avatarUrl;
final bool isContact;
const MentionCandidate({
required this.id,
required this.name,
this.avatarUrl,
this.isContact = false,
});
@override
bool operator ==(Object other) =>
other is MentionCandidate &&
other.id == id &&
other.name == name &&
other.isContact == isContact;
@override
int get hashCode => Object.hash(id, name, isContact);
}
class MentionQuery {
final int start;
final int end;
final String text;
const MentionQuery({
required this.start,
required this.end,
required this.text,
});
}
MentionQuery? mentionQueryAt(String text, int cursor) {
if (cursor <= 0 || cursor > text.length) return null;
const maxQueryLength = 32;
var index = cursor - 1;
while (index >= 0) {
final code = text.codeUnitAt(index);
if (code == 0x40) break;
if (code == 0x20 || code == 0x0A || code == 0x09) return null;
if (cursor - index > maxQueryLength) return null;
index--;
}
if (index < 0) return null;
if (index > 0) {
final before = text.codeUnitAt(index - 1);
if (before != 0x20 && before != 0x0A && before != 0x09) return null;
}
return MentionQuery(
start: index,
end: cursor,
text: text.substring(index + 1, cursor),
);
}
class MentionPanelController {
MentionPanelController({
required TickerProvider vsync,
required this.chatId,
required this.enabled,
required this.selfId,
required this.valueOf,
required this.onSelected,
}) {
anim = AnimationController(
vsync: vsync,
duration: const Duration(milliseconds: 200),
);
}
static const int _pageSize = 50;
static const int _desiredMatches = 30;
static const int _autoFetchLimit = 300;
final int chatId;
final bool Function() enabled;
final int Function() selfId;
final TextEditingValue Function() valueOf;
final void Function(MentionCandidate candidate, MentionQuery query)
onSelected;
late final AnimationController anim;
final ValueNotifier<List<MentionCandidate>> matches = ValueNotifier(const []);
final ValueNotifier<bool> loadingMore = ValueNotifier(false);
final List<MentionCandidate> _members = [];
final Set<int> _seen = {};
int _marker = 0;
bool _end = false;
bool _fetching = false;
bool _visible = false;
MentionQuery? _query;
bool get hasMore => !_end;
void update() {
final query = enabled() ? _queryAt(valueOf()) : null;
_query = query;
if (query == null) {
_setVisible(false);
return;
}
if (_members.isEmpty && !_end) unawaited(_fetchPage());
final found = _match(query.text);
if (!listEquals(matches.value, found)) matches.value = found;
if (found.length < _desiredMatches && _members.length < _autoFetchLimit) {
unawaited(_fetchPage());
}
_setVisible(found.isNotEmpty || (_members.isEmpty && !_end));
}
void select(MentionCandidate candidate) {
final query = _query;
if (query == null) return;
onSelected(candidate, query);
}
Future<void> loadMore() => _fetchPage();
MentionQuery? _queryAt(TextEditingValue value) {
final selection = value.selection;
if (!selection.isValid || !selection.isCollapsed) return null;
return mentionQueryAt(value.text, selection.baseOffset);
}
List<MentionCandidate> _match(String raw) {
final me = selfId();
final query = raw.toLowerCase().trim();
final found = _members
.where((c) => c.id != me)
.where((c) => query.isEmpty || _matchesQuery(c.name, query));
return [
...found.where((c) => c.isContact),
...found.where((c) => !c.isContact),
];
}
bool _matchesQuery(String name, String query) {
final lower = name.toLowerCase();
if (lower.startsWith(query)) return true;
for (final word in lower.split(' ')) {
if (word.startsWith(query)) return true;
}
return lower.contains(query);
}
Future<void> _fetchPage() async {
if (_fetching || _end) return;
_fetching = true;
loadingMore.value = true;
try {
final page = await chats.getChatMembers(
api,
chatId,
marker: _marker,
count: _pageSize,
);
if (page == null) {
_end = true;
return;
}
var added = 0;
for (final member in page.members) {
final name = member.fullName ?? member.name;
if (name == null || name.isEmpty) continue;
if (member.blocked) continue;
if (!_seen.add(member.id)) continue;
_members.add(
MentionCandidate(
id: member.id,
name: name,
avatarUrl: member.avatarUrl,
isContact: member.isContact,
),
);
added++;
}
if (added == 0 || page.members.isEmpty || page.marker == _marker) {
_end = true;
}
_marker = page.marker;
if (added > 0 && _query != null) {
final found = _match(_query!.text);
if (!listEquals(matches.value, found)) matches.value = found;
_setVisible(found.isNotEmpty);
}
} finally {
_fetching = false;
loadingMore.value = false;
}
}
void _setVisible(bool show) {
if (show == _visible) return;
_visible = show;
if (show) {
anim.forward();
} else {
anim.reverse();
}
}
void dispose() {
anim.dispose();
matches.dispose();
loadingMore.dispose();
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:komet/frontend/screens/chats/chat/mention_panel_controller.dart';
import 'package:komet/frontend/widgets/mention_suggestions_panel.dart';
class MentionPanelView extends StatelessWidget {
const MentionPanelView({super.key, required this.mentionPanel});
final MentionPanelController mentionPanel;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: mentionPanel.anim,
child: ValueListenableBuilder<List<MentionCandidate>>(
valueListenable: mentionPanel.matches,
builder: (context, matches, _) => ValueListenableBuilder<bool>(
valueListenable: mentionPanel.loadingMore,
builder: (context, loading, _) => MentionSuggestionsPanel(
candidates: matches,
loadingMore: loading && mentionPanel.hasMore,
onSelected: mentionPanel.select,
onLoadMore: mentionPanel.loadMore,
),
),
),
builder: (context, child) {
final t = mentionPanel.anim.value;
if (t == 0) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: IgnorePointer(
ignoring: t < 1,
child: Opacity(opacity: t, child: child),
),
);
},
);
}
}
@@ -17,6 +17,8 @@ import '../../widgets/animated_text_swap.dart';
import '../../widgets/avatar_history_screen.dart';
import '../../widgets/chat_info/shared_content_tabs.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/formatted_message_text.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/swipe_route.dart';
@@ -83,7 +85,8 @@ class ChatInfoScreen extends StatefulWidget {
State<ChatInfoScreen> createState() => _ChatInfoScreenState();
}
class _ChatInfoScreenState extends State<ChatInfoScreen> {
class _ChatInfoScreenState extends State<ChatInfoScreen>
with ReloadOnReconnect {
final _tabScrollController = ScrollController();
final _bodyScrollController = ScrollController();
@@ -175,6 +178,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
}
}
@override
void reloadAfterReconnect() => _load();
Future<void> _load() async {
final profile = await AppDatabase.loadActiveProfile();
_myId = profile?.id ?? 0;
@@ -789,7 +795,12 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
: int.tryParse(phone?.toString() ?? '');
if (phoneInt != null && phoneInt > 0) {
items.add(
_simpleInfoCard(cs, l10n.loginPhoneNumber, formatPhone(phoneInt)!),
_simpleInfoCard(
cs,
l10n.loginPhoneNumber,
formatPhone(phoneInt)!,
entities: true,
),
);
}
final bio =
@@ -797,7 +808,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
(_contactData?.raw['about'] as String?);
if (bio != null && bio.isNotEmpty) {
if (items.isNotEmpty) items.add(const SizedBox(height: 8));
items.add(_simpleInfoCard(cs, l10n.chatInfoBio, bio));
items.add(_simpleInfoCard(cs, l10n.chatInfoBio, bio, entities: true));
}
}
} else if (widget.chatType == 'CHANNEL') {
@@ -824,6 +835,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
String label,
String value, {
bool isLink = false,
bool entities = false,
}) {
return GlossyPill(
color: cs.surfaceContainerHigh,
@@ -840,14 +852,26 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
color: isLink ? cs.primary : cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
if (entities)
FormattedMessageText(
text: value,
ranges: const [],
entityMode: TextEntityMode.copy,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
)
else
Text(
value,
style: TextStyle(
color: isLink ? cs.primary : cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
@@ -905,8 +929,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 4),
Text(
desc,
FormattedMessageText(
text: desc,
ranges: const [],
entityMode: TextEntityMode.copy,
style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4),
maxLines: (_descExpanded || !isLong) ? null : collapsedLines,
overflow: (_descExpanded || !isLong)
+72 -6
View File
@@ -65,6 +65,8 @@ import 'chat/view/search_view.dart';
import 'chat/view/composer_input.dart';
import 'chat/view/sticker_panel_view.dart';
import 'chat/view/command_panel_view.dart';
import 'chat/view/mention_panel_view.dart';
import 'chat/mention_panel_controller.dart';
import 'chat/view/selection_bar.dart';
import 'chat/view/chat_header.dart';
import 'chat/view/shimmer_loading.dart';
@@ -93,6 +95,8 @@ import '../../widgets/sticker_pack_sheet.dart';
import '../../widgets/small_spinner.dart';
import '../../widgets/swipe_to_pop.dart';
import '../../widgets/swipe_route.dart';
import '../../widgets/directional_drag_recognizer.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/schedule_time_picker.dart';
import '../../widgets/chat_wallpaper_sheet.dart';
import '../../widgets/chat_wallpaper_view.dart';
@@ -236,7 +240,7 @@ class ChatScreen extends StatefulWidget {
}
class _ChatScreenState extends State<ChatScreen>
with TickerProviderStateMixin, WidgetsBindingObserver {
with TickerProviderStateMixin, WidgetsBindingObserver, ReloadOnReconnect {
final RichMessageController _messageController = RichMessageController();
final FocusNode _messageFocusNode = FocusNode();
double _keyboardReserve = 0;
@@ -454,6 +458,7 @@ class _ChatScreenState extends State<ChatScreen>
int _tempIdCounter = 0;
late final AnimationController _attachAnim;
late final CommandPanelController _commandPanel;
late final MentionPanelController _mentionPanel;
String _nextTempId() =>
'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
@@ -602,6 +607,14 @@ class _ChatScreenState extends State<ChatScreen>
textOf: () => _messageController.text,
onSelected: _onCommandSelected,
);
_mentionPanel = MentionPanelController(
vsync: this,
chatId: widget.chatId,
enabled: _mentionsAvailable,
selfId: () => _myId,
valueOf: () => _messageController.value,
onSelected: _onMentionSelected,
);
_selectionAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
@@ -683,6 +696,13 @@ class _ChatScreenState extends State<ChatScreen>
WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered);
}
@override
void reloadAfterReconnect() {
if (!_historyKickedOff) return;
unawaited(_loadHistory());
unawaited(_loadParticipantsCount());
}
Future<void> _loadParticipantsCount() async {
if (_commentsMode) return;
if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return;
@@ -1857,6 +1877,7 @@ class _ChatScreenState extends State<ChatScreen>
_uploadStatus.dispose();
_attachAnim.dispose();
_commandPanel.dispose();
_mentionPanel.dispose();
_selectionAnim.dispose();
_searchAnim.dispose();
_searchFocusNode.dispose();
@@ -1884,6 +1905,21 @@ class _ChatScreenState extends State<ChatScreen>
_hasText.value = newHasText;
}
_commandPanel.update();
_mentionPanel.update();
}
bool _mentionsAvailable() =>
!_commentsMode && (chat?.type ?? widget.chatType) == 'CHAT';
void _onMentionSelected(MentionCandidate candidate, MentionQuery query) {
_messageController.insertMention(
userId: candidate.id,
name: candidate.name,
start: query.start,
end: query.end,
);
_mentionPanel.update();
_messageFocusNode.requestFocus();
}
void _onCommandSelected(SlashCommand c) {
@@ -3371,6 +3407,8 @@ class _ChatScreenState extends State<ChatScreen>
return 'Ссылка';
case TextFormat.animoji:
return 'Animoji';
case TextFormat.userMention:
return 'Упоминание';
}
}
@@ -4794,7 +4832,13 @@ class _ChatScreenState extends State<ChatScreen>
left: 0,
right: 0,
bottom: frosted ? height : 0,
child: CommandPanelView(commandPanel: _commandPanel),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MentionPanelView(mentionPanel: _mentionPanel),
CommandPanelView(commandPanel: _commandPanel),
],
),
),
),
if (frosted)
@@ -4884,7 +4928,13 @@ class _ChatScreenState extends State<ChatScreen>
left: 0,
right: 0,
bottom: height,
child: CommandPanelView(commandPanel: _commandPanel),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MentionPanelView(mentionPanel: _mentionPanel),
CommandPanelView(commandPanel: _commandPanel),
],
),
),
),
Positioned(
@@ -6346,6 +6396,12 @@ class _SwipeToReplyState extends State<_SwipeToReply>
void _onDragEnd(DragEndDetails d) {
if (_triggered) widget.onReply();
_settle();
}
void _onDragCancel() => _settle();
void _settle() {
_triggered = false;
_springFrom = _dragX;
_springBack.forward(from: 0);
@@ -6355,10 +6411,20 @@ class _SwipeToReplyState extends State<_SwipeToReply>
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final progress = (-_dragX / _triggerThreshold).clamp(0.0, 1.0);
return GestureDetector(
return RawGestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: _onDragUpdate,
onHorizontalDragEnd: _onDragEnd,
gestures: <Type, GestureRecognizerFactory>{
LeftwardDragRecognizer:
GestureRecognizerFactoryWithHandlers<LeftwardDragRecognizer>(
() => LeftwardDragRecognizer(debugOwner: this),
(instance) {
instance
..onUpdate = _onDragUpdate
..onEnd = _onDragEnd
..onCancel = _onDragCancel;
},
),
},
child: Stack(
alignment: Alignment.centerRight,
children: [
@@ -18,6 +18,7 @@ import '../../widgets/custom_notification.dart';
import '../../widgets/schedule_time_picker.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart';
import '../../widgets/reload_on_reconnect.dart';
class ScheduledMessagesScreen extends StatefulWidget {
final int chatId;
@@ -36,7 +37,8 @@ class ScheduledMessagesScreen extends StatefulWidget {
_ScheduledMessagesScreenState();
}
class _ScheduledMessagesScreenState extends State<ScheduledMessagesScreen> {
class _ScheduledMessagesScreenState extends State<ScheduledMessagesScreen>
with ReloadOnReconnect {
final List<CachedMessage> _messages = [];
StreamSubscription<Packet>? _pushSub;
bool _loading = true;
@@ -61,6 +63,9 @@ class _ScheduledMessagesScreenState extends State<ScheduledMessagesScreen> {
super.dispose();
}
@override
void reloadAfterReconnect() => _load();
Future<void> _load() async {
final list = await messagesModule.fetchDelayedMessages(
widget.accountId,
@@ -10,6 +10,7 @@ import '../../../l10n/app_localizations.dart';
import '../../../main.dart' show digitalIdModule, webAppModule;
import '../../../models/digital_id.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/error_view.dart';
import '../../widgets/small_spinner.dart';
@@ -41,7 +42,8 @@ class DigitalIdScreen extends StatefulWidget {
State<DigitalIdScreen> createState() => _DigitalIdScreenState();
}
class _DigitalIdScreenState extends State<DigitalIdScreen> {
class _DigitalIdScreenState extends State<DigitalIdScreen>
with ReloadOnReconnect {
bool _loading = true;
bool _busy = false;
String? _error;
@@ -56,6 +58,9 @@ class _DigitalIdScreenState extends State<DigitalIdScreen> {
_load();
}
@override
void reloadAfterReconnect() => _load();
Future<void> _load() async {
setState(() {
_loading = true;
@@ -15,6 +15,7 @@ import '../../../core/utils/format.dart';
import '../../../l10n/app_localizations.dart';
import '../../../main.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/sheet_helpers.dart';
@@ -30,7 +31,7 @@ class CloudStorageScreen extends StatefulWidget {
}
class _CloudStorageScreenState extends State<CloudStorageScreen>
with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin, ReloadOnReconnect {
static const _translateFactor = 0.7;
static const _horizontalPadding = 32.0;
static const _hintSidePadding = 35.0;
@@ -185,6 +186,17 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
}
}
@override
void reloadAfterReconnect() {
final accountId = _accountId;
final groupId = _envGroupId;
if (accountId == null || groupId == null) {
_checkEnv();
return;
}
unawaited(_loadFiles(accountId, groupId));
}
Future<void> _loadFiles(int accountId, int chatId) async {
final files = await CloudStorageModule.fetchFiles(
messagesModule,
@@ -12,6 +12,7 @@ import '../../../main.dart' show accountModule;
import '../../../backend/modules/account.dart' show SessionInfo;
import '../../widgets/custom_notification.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/prompt_dialog.dart';
import '../../widgets/small_spinner.dart';
@@ -26,7 +27,7 @@ class DevicesScreen extends StatefulWidget {
}
class _DevicesScreenState extends State<DevicesScreen>
with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin, ReloadOnReconnect {
bool _isLoading = true;
List<SessionInfo> _sessions = [];
final Map<int, Map<String, dynamic>> _ipDetails = {};
@@ -50,6 +51,9 @@ class _DevicesScreenState extends State<DevicesScreen>
super.dispose();
}
@override
void reloadAfterReconnect() => _loadSessions();
Future<void> _loadSessions() async {
try {
final sessions = await accountModule.getSessions();
@@ -5,6 +5,7 @@ import '../../../core/utils/haptics.dart';
import '../../../l10n/app_localizations.dart';
import '../../../main.dart' show accountModule, isOnemeFlavor;
import '../../widgets/connection_status.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/section_header.dart';
import '../../widgets/settings_card.dart';
@@ -17,7 +18,8 @@ class NotificationsScreen extends StatefulWidget {
State<NotificationsScreen> createState() => _NotificationsScreenState();
}
class _NotificationsScreenState extends State<NotificationsScreen> {
class _NotificationsScreenState extends State<NotificationsScreen>
with ReloadOnReconnect {
bool _loading = true;
bool _saving = false;
@@ -34,6 +36,9 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
_load();
}
@override
void reloadAfterReconnect() => _load();
Future<void> _load() async {
final config = await accountModule.getPrivacyConfig();
if (!mounted) return;
@@ -10,6 +10,7 @@ import '../../../l10n/app_localizations.dart';
import '../../widgets/confirm_dialog.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart';
@@ -23,7 +24,7 @@ class SecurityScreen extends StatefulWidget {
}
class _SecurityScreenState extends State<SecurityScreen>
with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin, ReloadOnReconnect {
bool _isLoading = true;
bool _isSaving = false;
bool _is2faEnabled = false;
@@ -47,6 +48,9 @@ class _SecurityScreenState extends State<SecurityScreen>
super.dispose();
}
@override
void reloadAfterReconnect() => _loadData();
Future<void> _loadData() async {
try {
final results = await Future.wait([
@@ -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),
),
],
),
),
],
);
}
}
+24 -1
View File
@@ -16,11 +16,17 @@ class ContactName {
String? get label {
final n = name;
if (n != null && n.trim().isNotEmpty) return n.trim();
return fullName;
}
String? get fullName {
final combined = [firstName, lastName]
.where((s) => s != null && s.trim().isNotEmpty)
.map((s) => s!.trim())
.join(' ');
return combined.isEmpty ? null : combined;
if (combined.isNotEmpty) return combined;
final n = name;
return (n != null && n.trim().isNotEmpty) ? n.trim() : null;
}
}
@@ -52,6 +58,23 @@ class ContactInfo {
return firstLabel;
}
String? get customFullName => _fullNameOfType('CUSTOM');
String? get onemeFullName => _fullNameOfType('ONEME');
String? get fullName => customFullName ?? onemeFullName ?? displayName;
bool get isSavedContact => customFullName != null;
String? _fullNameOfType(String type) {
for (final n in names) {
if (n.type != type) continue;
final full = n.fullName;
if (full != null) return full;
}
return null;
}
String? get firstName {
for (final n in names) {
final f = n.firstName;