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 { class ChatMemberEntry {
final int id; final int id;
final String? name; final String? name;
final String? fullName;
final String? avatarUrl; final String? avatarUrl;
final int? seenTime; final int? seenTime;
final int presenceStatus; final int presenceStatus;
final bool blocked; final bool blocked;
final bool isContact;
const ChatMemberEntry({ const ChatMemberEntry({
required this.id, required this.id,
this.name, this.name,
this.fullName,
this.avatarUrl, this.avatarUrl,
this.seenTime, this.seenTime,
required this.presenceStatus, required this.presenceStatus,
this.blocked = false, this.blocked = false,
this.isContact = false,
}); });
bool get isOnline => presenceStatus == 1; bool get isOnline => presenceStatus == 1;
@@ -1865,10 +1869,12 @@ class ChatsModule {
ChatMemberEntry( ChatMemberEntry(
id: id, id: id,
name: name, name: name,
fullName: info.fullName,
avatarUrl: avatar, avatarUrl: avatar,
seenTime: seen, seenTime: seen,
presenceStatus: status, presenceStatus: status,
blocked: info.isDeleted, blocked: info.isDeleted,
isContact: info.isSavedContact,
), ),
); );
} }
+13 -4
View File
@@ -94,12 +94,21 @@ class AddContactResult {
class ContactsModule { class ContactsModule {
static final ValueNotifier<int> revision = ValueNotifier<int>(0); 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); final normalized = _normalizePhone(phone);
if (normalized == null) return null; if (normalized == null) return null;
final packet = await api.sendRequest(Opcode.contactInfoByPhone, { final Packet packet;
'phone': normalized, try {
}); packet = await api.sendRequest(Opcode.contactInfoByPhone, {
'phone': normalized,
}, silent: silent);
} on PacketError {
return null;
}
if (packet.isError) return null; if (packet.isError) return null;
final contact = (packet.payload as Map?)?['contact']; final contact = (packet.payload as Map?)?['contact'];
if (contact is! Map) return null; 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 { static Future<ResolvedLink?> resolve(Api api, String url) async {
final Packet response; final Packet response;
try { try {
response = await api.sendRequest(Opcode.linkInfo, {'link': url}); response = await api.sendRequest(Opcode.linkInfo, {
'link': url,
}, silent: true);
} on TimeoutException { } on TimeoutException {
return const ResolvedLinkError('Превышено время ожидания'); return const ResolvedLinkError('Превышено время ожидания');
} on PacketError catch (e) { } 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, quote,
link, link,
animoji, animoji,
userMention,
} }
const Map<TextFormat, String> _formatToServer = { const Map<TextFormat, String> _formatToServer = {
@@ -20,6 +21,7 @@ const Map<TextFormat, String> _formatToServer = {
TextFormat.quote: 'QUOTE', TextFormat.quote: 'QUOTE',
TextFormat.link: 'LINK', TextFormat.link: 'LINK',
TextFormat.animoji: 'ANIMOJI', TextFormat.animoji: 'ANIMOJI',
TextFormat.userMention: 'USER_MENTION',
}; };
final Map<String, TextFormat> _serverToFormat = { final Map<String, TextFormat> _serverToFormat = {
@@ -35,12 +37,16 @@ class FormatRange {
final TextFormat format; final TextFormat format;
final int start; final int start;
final int length; final int length;
final int? entityId;
final String? entityName;
final Map<String, dynamic>? attributes; final Map<String, dynamic>? attributes;
const FormatRange({ const FormatRange({
required this.format, required this.format,
required this.start, required this.start,
required this.length, required this.length,
this.entityId,
this.entityName,
this.attributes, this.attributes,
}); });
@@ -60,6 +66,8 @@ class FormatRange {
'type': textFormatToServer(format), 'type': textFormatToServer(format),
'from': start, 'from': start,
'length': length, 'length': length,
if (entityId != null) 'entityId': entityId,
if (entityName != null) 'entityName': entityName,
if (attributes != null) 'attributes': attributes, if (attributes != null) 'attributes': attributes,
}; };
} }
@@ -78,11 +86,17 @@ List<FormatRange> parseFormatElements(dynamic raw) {
final attributes = attrsRaw is Map final attributes = attrsRaw is Map
? Map<String, dynamic>.from(attrsRaw) ? Map<String, dynamic>.from(attrsRaw)
: null; : null;
final entityId = item['entityId'];
final entityName = item['entityName'];
result.add( result.add(
FormatRange( FormatRange(
format: format, format: format,
start: from, start: from,
length: length, length: length,
entityId: entityId is int ? entityId : null,
entityName: entityName is String && entityName.isNotEmpty
? entityName
: null,
attributes: attributes, attributes: attributes,
), ),
); );
@@ -134,6 +148,8 @@ class FormatSegment {
final Set<TextFormat> formats; final Set<TextFormat> formats;
final String? url; final String? url;
final String? animojiUrl; final String? animojiUrl;
final int? mentionId;
final String? mentionName;
const FormatSegment({ const FormatSegment({
required this.start, required this.start,
@@ -141,6 +157,8 @@ class FormatSegment {
required this.formats, required this.formats,
this.url, this.url,
this.animojiUrl, this.animojiUrl,
this.mentionId,
this.mentionName,
}); });
} }
@@ -157,6 +175,8 @@ List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
format: range.format, format: range.format,
start: start, start: start,
length: end - start, length: end - start,
entityId: range.entityId,
entityName: range.entityName,
attributes: range.attributes, attributes: range.attributes,
), ),
); );
@@ -180,11 +200,17 @@ List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
final formats = <TextFormat>{}; final formats = <TextFormat>{};
String? url; String? url;
String? animojiUrl; String? animojiUrl;
int? mentionId;
String? mentionName;
for (final range in clamped) { for (final range in clamped) {
if (range.start <= start && range.end >= end) { if (range.start <= start && range.end >= end) {
formats.add(range.format); formats.add(range.format);
if (range.format == TextFormat.link) url ??= range.url; if (range.format == TextFormat.link) url ??= range.url;
if (range.format == TextFormat.animoji) animojiUrl ??= range.animojiUrl; if (range.format == TextFormat.animoji) animojiUrl ??= range.animojiUrl;
if (range.format == TextFormat.userMention) {
mentionId ??= range.entityId;
mentionName ??= range.entityName;
}
} }
} }
segments.add( segments.add(
@@ -194,6 +220,8 @@ List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
formats: formats, formats: formats,
url: url, url: url,
animojiUrl: animojiUrl, animojiUrl: animojiUrl,
mentionId: mentionId,
mentionName: mentionName,
), ),
); );
} }
@@ -204,6 +232,7 @@ TextStyle applyTextFormats(
TextStyle base, TextStyle base,
Set<TextFormat> formats, { Set<TextFormat> formats, {
Color? quoteColor, Color? quoteColor,
Color? mentionColor,
}) { }) {
if (formats.isEmpty) return base; if (formats.isEmpty) return base;
@@ -219,11 +248,15 @@ TextStyle applyTextFormats(
final isItalic = formats.contains(TextFormat.emphasized) || final isItalic = formats.contains(TextFormat.emphasized) ||
formats.contains(TextFormat.quote); formats.contains(TextFormat.quote);
final isMention = formats.contains(TextFormat.userMention);
return base.copyWith( return base.copyWith(
fontWeight: formats.contains(TextFormat.strong) ? FontWeight.w700 : null, fontWeight: formats.contains(TextFormat.strong) ? FontWeight.w700 : null,
fontStyle: isItalic ? FontStyle.italic : null, fontStyle: isItalic ? FontStyle.italic : null,
fontFamily: formats.contains(TextFormat.monospaced) ? 'monospace' : 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 decoration: decorations.isEmpty
? null ? null
: TextDecoration.combine(decorations), : TextDecoration.combine(decorations),
+7 -1
View File
@@ -10,6 +10,7 @@ import '../../../core/calls/call_controller.dart';
import '../../../backend/modules/calls.dart'; import '../../../backend/modules/calls.dart';
import '../../widgets/komet_avatar.dart'; import '../../widgets/komet_avatar.dart';
import '../../widgets/connection_status.dart'; import '../../widgets/connection_status.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/chat_menu_overlay.dart'; import '../../widgets/chat_menu_overlay.dart';
import '../../widgets/small_spinner.dart'; import '../../widgets/small_spinner.dart';
@@ -22,7 +23,7 @@ class CallsTab extends StatefulWidget {
State<CallsTab> createState() => _CallsTabState(); State<CallsTab> createState() => _CallsTabState();
} }
class _CallsTabState extends State<CallsTab> { class _CallsTabState extends State<CallsTab> with ReloadOnReconnect {
List<CallLogEntry> _calls = []; List<CallLogEntry> _calls = [];
final Set<String> _removing = {}; final Set<String> _removing = {};
bool _isLoading = true; bool _isLoading = true;
@@ -51,6 +52,11 @@ class _CallsTabState extends State<CallsTab> {
super.dispose(); super.dispose();
} }
@override
void reloadAfterReconnect() {
if (accountModule.isLoggedIn) _loadHistory();
}
Future<void> _loadHistory() async { Future<void> _loadHistory() async {
final p = await AppDatabase.loadActiveProfile(); final p = await AppDatabase.loadActiveProfile();
if (p == null) { 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/avatar_history_screen.dart';
import '../../widgets/chat_info/shared_content_tabs.dart'; import '../../widgets/chat_info/shared_content_tabs.dart';
import '../../widgets/connection_status.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/glossy_pill.dart';
import '../../widgets/komet_avatar.dart'; import '../../widgets/komet_avatar.dart';
import '../../widgets/swipe_route.dart'; import '../../widgets/swipe_route.dart';
@@ -83,7 +85,8 @@ class ChatInfoScreen extends StatefulWidget {
State<ChatInfoScreen> createState() => _ChatInfoScreenState(); State<ChatInfoScreen> createState() => _ChatInfoScreenState();
} }
class _ChatInfoScreenState extends State<ChatInfoScreen> { class _ChatInfoScreenState extends State<ChatInfoScreen>
with ReloadOnReconnect {
final _tabScrollController = ScrollController(); final _tabScrollController = ScrollController();
final _bodyScrollController = ScrollController(); final _bodyScrollController = ScrollController();
@@ -175,6 +178,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
} }
} }
@override
void reloadAfterReconnect() => _load();
Future<void> _load() async { Future<void> _load() async {
final profile = await AppDatabase.loadActiveProfile(); final profile = await AppDatabase.loadActiveProfile();
_myId = profile?.id ?? 0; _myId = profile?.id ?? 0;
@@ -789,7 +795,12 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
: int.tryParse(phone?.toString() ?? ''); : int.tryParse(phone?.toString() ?? '');
if (phoneInt != null && phoneInt > 0) { if (phoneInt != null && phoneInt > 0) {
items.add( items.add(
_simpleInfoCard(cs, l10n.loginPhoneNumber, formatPhone(phoneInt)!), _simpleInfoCard(
cs,
l10n.loginPhoneNumber,
formatPhone(phoneInt)!,
entities: true,
),
); );
} }
final bio = final bio =
@@ -797,7 +808,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
(_contactData?.raw['about'] as String?); (_contactData?.raw['about'] as String?);
if (bio != null && bio.isNotEmpty) { if (bio != null && bio.isNotEmpty) {
if (items.isNotEmpty) items.add(const SizedBox(height: 8)); 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') { } else if (widget.chatType == 'CHANNEL') {
@@ -824,6 +835,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
String label, String label,
String value, { String value, {
bool isLink = false, bool isLink = false,
bool entities = false,
}) { }) {
return GlossyPill( return GlossyPill(
color: cs.surfaceContainerHigh, color: cs.surfaceContainerHigh,
@@ -840,14 +852,26 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( if (entities)
value, FormattedMessageText(
style: TextStyle( text: value,
color: isLink ? cs.primary : cs.onSurface, ranges: const [],
fontSize: 16, entityMode: TextEntityMode.copy,
fontWeight: FontWeight.w500, 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), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( FormattedMessageText(
desc, text: desc,
ranges: const [],
entityMode: TextEntityMode.copy,
style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4), style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4),
maxLines: (_descExpanded || !isLong) ? null : collapsedLines, maxLines: (_descExpanded || !isLong) ? null : collapsedLines,
overflow: (_descExpanded || !isLong) 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/composer_input.dart';
import 'chat/view/sticker_panel_view.dart'; import 'chat/view/sticker_panel_view.dart';
import 'chat/view/command_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/selection_bar.dart';
import 'chat/view/chat_header.dart'; import 'chat/view/chat_header.dart';
import 'chat/view/shimmer_loading.dart'; import 'chat/view/shimmer_loading.dart';
@@ -93,6 +95,8 @@ import '../../widgets/sticker_pack_sheet.dart';
import '../../widgets/small_spinner.dart'; import '../../widgets/small_spinner.dart';
import '../../widgets/swipe_to_pop.dart'; import '../../widgets/swipe_to_pop.dart';
import '../../widgets/swipe_route.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/schedule_time_picker.dart';
import '../../widgets/chat_wallpaper_sheet.dart'; import '../../widgets/chat_wallpaper_sheet.dart';
import '../../widgets/chat_wallpaper_view.dart'; import '../../widgets/chat_wallpaper_view.dart';
@@ -236,7 +240,7 @@ class ChatScreen extends StatefulWidget {
} }
class _ChatScreenState extends State<ChatScreen> class _ChatScreenState extends State<ChatScreen>
with TickerProviderStateMixin, WidgetsBindingObserver { with TickerProviderStateMixin, WidgetsBindingObserver, ReloadOnReconnect {
final RichMessageController _messageController = RichMessageController(); final RichMessageController _messageController = RichMessageController();
final FocusNode _messageFocusNode = FocusNode(); final FocusNode _messageFocusNode = FocusNode();
double _keyboardReserve = 0; double _keyboardReserve = 0;
@@ -454,6 +458,7 @@ class _ChatScreenState extends State<ChatScreen>
int _tempIdCounter = 0; int _tempIdCounter = 0;
late final AnimationController _attachAnim; late final AnimationController _attachAnim;
late final CommandPanelController _commandPanel; late final CommandPanelController _commandPanel;
late final MentionPanelController _mentionPanel;
String _nextTempId() => String _nextTempId() =>
'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
@@ -602,6 +607,14 @@ class _ChatScreenState extends State<ChatScreen>
textOf: () => _messageController.text, textOf: () => _messageController.text,
onSelected: _onCommandSelected, onSelected: _onCommandSelected,
); );
_mentionPanel = MentionPanelController(
vsync: this,
chatId: widget.chatId,
enabled: _mentionsAvailable,
selfId: () => _myId,
valueOf: () => _messageController.value,
onSelected: _onMentionSelected,
);
_selectionAnim = AnimationController( _selectionAnim = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 260), duration: const Duration(milliseconds: 260),
@@ -683,6 +696,13 @@ class _ChatScreenState extends State<ChatScreen>
WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered); WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered);
} }
@override
void reloadAfterReconnect() {
if (!_historyKickedOff) return;
unawaited(_loadHistory());
unawaited(_loadParticipantsCount());
}
Future<void> _loadParticipantsCount() async { Future<void> _loadParticipantsCount() async {
if (_commentsMode) return; if (_commentsMode) return;
if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return; if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return;
@@ -1857,6 +1877,7 @@ class _ChatScreenState extends State<ChatScreen>
_uploadStatus.dispose(); _uploadStatus.dispose();
_attachAnim.dispose(); _attachAnim.dispose();
_commandPanel.dispose(); _commandPanel.dispose();
_mentionPanel.dispose();
_selectionAnim.dispose(); _selectionAnim.dispose();
_searchAnim.dispose(); _searchAnim.dispose();
_searchFocusNode.dispose(); _searchFocusNode.dispose();
@@ -1884,6 +1905,21 @@ class _ChatScreenState extends State<ChatScreen>
_hasText.value = newHasText; _hasText.value = newHasText;
} }
_commandPanel.update(); _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) { void _onCommandSelected(SlashCommand c) {
@@ -3371,6 +3407,8 @@ class _ChatScreenState extends State<ChatScreen>
return 'Ссылка'; return 'Ссылка';
case TextFormat.animoji: case TextFormat.animoji:
return 'Animoji'; return 'Animoji';
case TextFormat.userMention:
return 'Упоминание';
} }
} }
@@ -4794,7 +4832,13 @@ class _ChatScreenState extends State<ChatScreen>
left: 0, left: 0,
right: 0, right: 0,
bottom: frosted ? height : 0, bottom: frosted ? height : 0,
child: CommandPanelView(commandPanel: _commandPanel), child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MentionPanelView(mentionPanel: _mentionPanel),
CommandPanelView(commandPanel: _commandPanel),
],
),
), ),
), ),
if (frosted) if (frosted)
@@ -4884,7 +4928,13 @@ class _ChatScreenState extends State<ChatScreen>
left: 0, left: 0,
right: 0, right: 0,
bottom: height, bottom: height,
child: CommandPanelView(commandPanel: _commandPanel), child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MentionPanelView(mentionPanel: _mentionPanel),
CommandPanelView(commandPanel: _commandPanel),
],
),
), ),
), ),
Positioned( Positioned(
@@ -6346,6 +6396,12 @@ class _SwipeToReplyState extends State<_SwipeToReply>
void _onDragEnd(DragEndDetails d) { void _onDragEnd(DragEndDetails d) {
if (_triggered) widget.onReply(); if (_triggered) widget.onReply();
_settle();
}
void _onDragCancel() => _settle();
void _settle() {
_triggered = false; _triggered = false;
_springFrom = _dragX; _springFrom = _dragX;
_springBack.forward(from: 0); _springBack.forward(from: 0);
@@ -6355,10 +6411,20 @@ class _SwipeToReplyState extends State<_SwipeToReply>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final progress = (-_dragX / _triggerThreshold).clamp(0.0, 1.0); final progress = (-_dragX / _triggerThreshold).clamp(0.0, 1.0);
return GestureDetector( return RawGestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: _onDragUpdate, gestures: <Type, GestureRecognizerFactory>{
onHorizontalDragEnd: _onDragEnd, LeftwardDragRecognizer:
GestureRecognizerFactoryWithHandlers<LeftwardDragRecognizer>(
() => LeftwardDragRecognizer(debugOwner: this),
(instance) {
instance
..onUpdate = _onDragUpdate
..onEnd = _onDragEnd
..onCancel = _onDragCancel;
},
),
},
child: Stack( child: Stack(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
children: [ children: [
@@ -18,6 +18,7 @@ import '../../widgets/custom_notification.dart';
import '../../widgets/schedule_time_picker.dart'; import '../../widgets/schedule_time_picker.dart';
import '../../widgets/sheet_helpers.dart'; import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart'; import '../../widgets/small_spinner.dart';
import '../../widgets/reload_on_reconnect.dart';
class ScheduledMessagesScreen extends StatefulWidget { class ScheduledMessagesScreen extends StatefulWidget {
final int chatId; final int chatId;
@@ -36,7 +37,8 @@ class ScheduledMessagesScreen extends StatefulWidget {
_ScheduledMessagesScreenState(); _ScheduledMessagesScreenState();
} }
class _ScheduledMessagesScreenState extends State<ScheduledMessagesScreen> { class _ScheduledMessagesScreenState extends State<ScheduledMessagesScreen>
with ReloadOnReconnect {
final List<CachedMessage> _messages = []; final List<CachedMessage> _messages = [];
StreamSubscription<Packet>? _pushSub; StreamSubscription<Packet>? _pushSub;
bool _loading = true; bool _loading = true;
@@ -61,6 +63,9 @@ class _ScheduledMessagesScreenState extends State<ScheduledMessagesScreen> {
super.dispose(); super.dispose();
} }
@override
void reloadAfterReconnect() => _load();
Future<void> _load() async { Future<void> _load() async {
final list = await messagesModule.fetchDelayedMessages( final list = await messagesModule.fetchDelayedMessages(
widget.accountId, widget.accountId,
@@ -10,6 +10,7 @@ import '../../../l10n/app_localizations.dart';
import '../../../main.dart' show digitalIdModule, webAppModule; import '../../../main.dart' show digitalIdModule, webAppModule;
import '../../../models/digital_id.dart'; import '../../../models/digital_id.dart';
import '../../widgets/connection_status.dart'; import '../../widgets/connection_status.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/error_view.dart'; import '../../widgets/error_view.dart';
import '../../widgets/small_spinner.dart'; import '../../widgets/small_spinner.dart';
@@ -41,7 +42,8 @@ class DigitalIdScreen extends StatefulWidget {
State<DigitalIdScreen> createState() => _DigitalIdScreenState(); State<DigitalIdScreen> createState() => _DigitalIdScreenState();
} }
class _DigitalIdScreenState extends State<DigitalIdScreen> { class _DigitalIdScreenState extends State<DigitalIdScreen>
with ReloadOnReconnect {
bool _loading = true; bool _loading = true;
bool _busy = false; bool _busy = false;
String? _error; String? _error;
@@ -56,6 +58,9 @@ class _DigitalIdScreenState extends State<DigitalIdScreen> {
_load(); _load();
} }
@override
void reloadAfterReconnect() => _load();
Future<void> _load() async { Future<void> _load() async {
setState(() { setState(() {
_loading = true; _loading = true;
@@ -15,6 +15,7 @@ import '../../../core/utils/format.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../widgets/connection_status.dart'; import '../../widgets/connection_status.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart'; import '../../widgets/glossy_pill.dart';
import '../../widgets/sheet_helpers.dart'; import '../../widgets/sheet_helpers.dart';
@@ -30,7 +31,7 @@ class CloudStorageScreen extends StatefulWidget {
} }
class _CloudStorageScreenState extends State<CloudStorageScreen> class _CloudStorageScreenState extends State<CloudStorageScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin, ReloadOnReconnect {
static const _translateFactor = 0.7; static const _translateFactor = 0.7;
static const _horizontalPadding = 32.0; static const _horizontalPadding = 32.0;
static const _hintSidePadding = 35.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 { Future<void> _loadFiles(int accountId, int chatId) async {
final files = await CloudStorageModule.fetchFiles( final files = await CloudStorageModule.fetchFiles(
messagesModule, messagesModule,
@@ -12,6 +12,7 @@ import '../../../main.dart' show accountModule;
import '../../../backend/modules/account.dart' show SessionInfo; import '../../../backend/modules/account.dart' show SessionInfo;
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/connection_status.dart'; import '../../widgets/connection_status.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/glossy_pill.dart'; import '../../widgets/glossy_pill.dart';
import '../../widgets/prompt_dialog.dart'; import '../../widgets/prompt_dialog.dart';
import '../../widgets/small_spinner.dart'; import '../../widgets/small_spinner.dart';
@@ -26,7 +27,7 @@ class DevicesScreen extends StatefulWidget {
} }
class _DevicesScreenState extends State<DevicesScreen> class _DevicesScreenState extends State<DevicesScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin, ReloadOnReconnect {
bool _isLoading = true; bool _isLoading = true;
List<SessionInfo> _sessions = []; List<SessionInfo> _sessions = [];
final Map<int, Map<String, dynamic>> _ipDetails = {}; final Map<int, Map<String, dynamic>> _ipDetails = {};
@@ -50,6 +51,9 @@ class _DevicesScreenState extends State<DevicesScreen>
super.dispose(); super.dispose();
} }
@override
void reloadAfterReconnect() => _loadSessions();
Future<void> _loadSessions() async { Future<void> _loadSessions() async {
try { try {
final sessions = await accountModule.getSessions(); final sessions = await accountModule.getSessions();
@@ -5,6 +5,7 @@ import '../../../core/utils/haptics.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../main.dart' show accountModule, isOnemeFlavor; import '../../../main.dart' show accountModule, isOnemeFlavor;
import '../../widgets/connection_status.dart'; import '../../widgets/connection_status.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/section_header.dart'; import '../../widgets/section_header.dart';
import '../../widgets/settings_card.dart'; import '../../widgets/settings_card.dart';
@@ -17,7 +18,8 @@ class NotificationsScreen extends StatefulWidget {
State<NotificationsScreen> createState() => _NotificationsScreenState(); State<NotificationsScreen> createState() => _NotificationsScreenState();
} }
class _NotificationsScreenState extends State<NotificationsScreen> { class _NotificationsScreenState extends State<NotificationsScreen>
with ReloadOnReconnect {
bool _loading = true; bool _loading = true;
bool _saving = false; bool _saving = false;
@@ -34,6 +36,9 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
_load(); _load();
} }
@override
void reloadAfterReconnect() => _load();
Future<void> _load() async { Future<void> _load() async {
final config = await accountModule.getPrivacyConfig(); final config = await accountModule.getPrivacyConfig();
if (!mounted) return; if (!mounted) return;
@@ -10,6 +10,7 @@ import '../../../l10n/app_localizations.dart';
import '../../widgets/confirm_dialog.dart'; import '../../widgets/confirm_dialog.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/connection_status.dart'; import '../../widgets/connection_status.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/glossy_pill.dart'; import '../../widgets/glossy_pill.dart';
import '../../widgets/sheet_helpers.dart'; import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart'; import '../../widgets/small_spinner.dart';
@@ -23,7 +24,7 @@ class SecurityScreen extends StatefulWidget {
} }
class _SecurityScreenState extends State<SecurityScreen> class _SecurityScreenState extends State<SecurityScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin, ReloadOnReconnect {
bool _isLoading = true; bool _isLoading = true;
bool _isSaving = false; bool _isSaving = false;
bool _is2faEnabled = false; bool _is2faEnabled = false;
@@ -47,6 +48,9 @@ class _SecurityScreenState extends State<SecurityScreen>
super.dispose(); super.dispose();
} }
@override
void reloadAfterReconnect() => _loadData();
Future<void> _loadData() async { Future<void> _loadData() async {
try { try {
final results = await Future.wait([ 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 '../custom_notification.dart';
import '../komet_avatar.dart'; import '../komet_avatar.dart';
import '../photo_viewer.dart'; import '../photo_viewer.dart';
import '../reload_on_reconnect.dart';
import '../small_spinner.dart'; import '../small_spinner.dart';
import '../swipe_route.dart'; import '../swipe_route.dart';
import '../video_player_screen.dart'; import '../video_player_screen.dart';
@@ -312,7 +313,8 @@ class CommonChatsTab extends StatefulWidget {
State<CommonChatsTab> createState() => _CommonChatsTabState(); State<CommonChatsTab> createState() => _CommonChatsTabState();
} }
class _CommonChatsTabState extends State<CommonChatsTab> { class _CommonChatsTabState extends State<CommonChatsTab>
with ReloadOnReconnect {
bool _loading = true; bool _loading = true;
List<CommonChatEntry> _chats = const []; List<CommonChatEntry> _chats = const [];
Map<int, int> _onlineByChat = const {}; Map<int, int> _onlineByChat = const {};
@@ -323,6 +325,9 @@ class _CommonChatsTabState extends State<CommonChatsTab> {
_load(); _load();
} }
@override
void reloadAfterReconnect() => _load();
Future<void> _load() async { Future<void> _load() async {
final chats = await sharedContentModule.fetchCommonChats(widget.userId); final chats = await sharedContentModule.fetchCommonChats(widget.userId);
@@ -474,7 +479,8 @@ class SharedMediaTab extends StatefulWidget {
State<SharedMediaTab> createState() => _SharedMediaTabState(); State<SharedMediaTab> createState() => _SharedMediaTabState();
} }
class _SharedMediaTabState extends State<SharedMediaTab> { class _SharedMediaTabState extends State<SharedMediaTab>
with ReloadOnReconnect {
static const int _pageSize = 60; static const int _pageSize = 60;
bool _loading = true; 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 { Future<void> _load(String anchor, {required bool initial}) async {
final page = await sharedContentModule.fetchMedia( final page = await sharedContentModule.fetchMedia(
chatId: widget.chatId, chatId: widget.chatId,
+104 -53
View File
@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -26,6 +28,8 @@ void showChatMenu({
required BuildContext context, required BuildContext context,
required Rect anchorRect, required Rect anchorRect,
required List<ChatMenuItem> items, required List<ChatMenuItem> items,
Widget? header,
Widget? footer,
}) { }) {
final overlay = Overlay.of(context, rootOverlay: true); final overlay = Overlay.of(context, rootOverlay: true);
late OverlayEntry entry; late OverlayEntry entry;
@@ -33,6 +37,8 @@ void showChatMenu({
builder: (ctx) => _ChatMenuLayer( builder: (ctx) => _ChatMenuLayer(
anchorRect: anchorRect, anchorRect: anchorRect,
items: items, items: items,
header: header,
footer: footer,
onDismiss: () { onDismiss: () {
if (entry.mounted) entry.remove(); if (entry.mounted) entry.remove();
}, },
@@ -45,25 +51,72 @@ void showChatMenu({
class _ChatMenuLayer extends StatefulWidget { class _ChatMenuLayer extends StatefulWidget {
final Rect anchorRect; final Rect anchorRect;
final List<ChatMenuItem> items; final List<ChatMenuItem> items;
final Widget? header;
final Widget? footer;
final VoidCallback onDismiss; final VoidCallback onDismiss;
const _ChatMenuLayer({ const _ChatMenuLayer({
required this.anchorRect, required this.anchorRect,
required this.items, required this.items,
required this.onDismiss, required this.onDismiss,
this.header,
this.footer,
}); });
@override @override
State<_ChatMenuLayer> createState() => _ChatMenuLayerState(); 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> class _ChatMenuLayerState extends State<_ChatMenuLayer>
with SingleTickerProviderStateMixin, AnimatedOverlayPopup<_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 @override
Duration get overlayForwardDuration => const Duration(milliseconds: 220); Duration get overlayForwardDuration => const Duration(milliseconds: 220);
@@ -78,29 +131,10 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer>
closeOverlay().then((_) => item.onTap?.call()); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final screen = MediaQuery.sizeOf(context); final safeArea = MediaQuery.paddingOf(context);
final bottomInset = MediaQuery.paddingOf(context).bottom;
final rect = _resolveRect(screen);
final maxHeight = (screen.height - rect.top - bottomInset - _vMargin).clamp(
120.0,
double.infinity,
);
return AnimatedBuilder( return AnimatedBuilder(
animation: overlayAnimation, animation: overlayAnimation,
builder: (ctx, child) { builder: (ctx, child) {
@@ -115,16 +149,19 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer>
child: const SizedBox.expand(), child: const SizedBox.expand(),
), ),
), ),
Positioned( Positioned.fill(
left: rect.left, child: CustomSingleChildLayout(
top: rect.top, delegate: _MenuLayout(
width: rect.width, anchor: widget.anchorRect,
child: Opacity( safeArea: safeArea,
opacity: t, ),
child: Transform.scale( child: Opacity(
scale: scale, opacity: t,
alignment: Alignment.topRight, child: Transform.scale(
child: child, scale: scale,
alignment: Alignment.topRight,
child: child,
),
), ),
), ),
), ),
@@ -137,25 +174,39 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer>
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
elevation: 12, elevation: 12,
shadowColor: Colors.black.withValues(alpha: 0.45), shadowColor: Colors.black.withValues(alpha: 0.45),
child: ConstrainedBox( child: SingleChildScrollView(
constraints: BoxConstraints(maxHeight: maxHeight), child: Column(
child: SingleChildScrollView( mainAxisSize: MainAxisSize.min,
child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, children: [
children: [ if (widget.header != null) ...[
const SizedBox(height: 6), widget.header!,
for (final item in widget.items) ...[ Divider(
_ChatMenuRow(item: item, onTap: () => _onItemTap(item)), height: 1,
if (item.dividerAfter) thickness: 1,
Divider( color: cs.onSurface.withValues(alpha: 0.07),
height: 1, ),
thickness: 1,
color: cs.onSurface.withValues(alpha: 0.07),
),
],
const SizedBox(height: 6),
], ],
), 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'; import 'package:flutter/gestures.dart';
class RightwardDragRecognizer extends HorizontalDragGestureRecognizer { class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer {
RightwardDragRecognizer({super.debugOwner}) { DirectionalDragRecognizer({
required this.direction,
this.minAcceptDistance = 20.0,
this.minAcceptVelocity,
super.debugOwner,
}) {
onlyAcceptDragOnThreshold = true; onlyAcceptDragOnThreshold = true;
} }
static const double _kMinAcceptVelocity = 700.0; final double direction;
static const double _kMinAcceptDistance = 20.0; final double minAcceptDistance;
final double? minAcceptVelocity;
final Map<int, Offset> _initialPositions = {}; final Map<int, Offset> _initialPositions = {};
final Map<int, VelocityTracker> _velocityTrackers = {}; final Map<int, VelocityTracker> _velocityTrackers = {};
@@ -30,7 +36,7 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer {
); );
final initial = _initialPositions[event.pointer]; final initial = _initialPositions[event.pointer];
if (initial != null) { if (initial != null) {
final dx = event.position.dx - initial.dx; final dx = (event.position.dx - initial.dx) * direction;
_currentDeltaX[event.pointer] = dx; _currentDeltaX[event.pointer] = dx;
if (dx < -kTouchSlop) { if (dx < -kTouchSlop) {
stopTrackingPointer(event.pointer); stopTrackingPointer(event.pointer);
@@ -57,10 +63,13 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer {
for (final dx in _currentDeltaX.values) { for (final dx in _currentDeltaX.values) {
if (dx > maxDx) maxDx = dx; 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) { for (final tracker in _velocityTrackers.values) {
final vx = tracker.getVelocity().pixelsPerSecond.dx; final vx = tracker.getVelocity().pixelsPerSecond.dx * direction;
if (vx >= _kMinAcceptVelocity) return true; if (vx >= minVelocity) return true;
} }
return false; return false;
} }
@@ -83,3 +92,12 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer {
super.rejectGesture(pointer); 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/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../backend/modules/messages.dart' show ContactCache;
import '../../core/utils/link_opener.dart'; import '../../core/utils/link_opener.dart';
import '../../core/utils/text_entities.dart';
import '../../core/utils/text_format.dart'; import '../../core/utils/text_format.dart';
import '../screens/contacts/open_contact_profile.dart';
import 'link_text.dart'; import 'link_text.dart';
import 'lottie_image.dart'; import 'lottie_image.dart';
import 'text_entity_actions.dart';
Color mentionTextColor(ColorScheme cs) => cs.primary;
enum TextEntityMode { menu, copy }
class FormattedMessageText extends StatefulWidget { class FormattedMessageText extends StatefulWidget {
final String text; final String text;
final List<FormatRange> ranges; final List<FormatRange> ranges;
final TextStyle style; final TextStyle style;
final TextAlign textAlign; final TextAlign textAlign;
final TextEntityMode entityMode;
final int? maxLines;
final TextOverflow? overflow;
const FormattedMessageText({ const FormattedMessageText({
super.key, super.key,
@@ -18,18 +31,22 @@ class FormattedMessageText extends StatefulWidget {
required this.ranges, required this.ranges,
required this.style, required this.style,
this.textAlign = TextAlign.start, this.textAlign = TextAlign.start,
this.entityMode = TextEntityMode.menu,
this.maxLines,
this.overflow,
}); });
static bool isFormatted(String? text, List<FormatRange> ranges) => static bool isFormatted(String? text, List<FormatRange> ranges) =>
text != null && text != null &&
text.isNotEmpty && text.isNotEmpty &&
(ranges.isNotEmpty || LinkText.hasLinks(text)); (ranges.isNotEmpty || LinkText.hasLinks(text) || hasTextEntities(text));
static TextSpan buildInlineSpan( static TextSpan buildInlineSpan(
String text, String text,
List<FormatRange> ranges, List<FormatRange> ranges,
TextStyle style, TextStyle style, {
) { Color? mentionColor,
}) {
final quoteColor = style.color?.withValues(alpha: 0.85); final quoteColor = style.color?.withValues(alpha: 0.85);
final segments = segmentizeFormats(text, ranges); final segments = segmentizeFormats(text, ranges);
return TextSpan( return TextSpan(
@@ -42,6 +59,7 @@ class FormattedMessageText extends StatefulWidget {
style, style,
segment.formats, segment.formats,
quoteColor: quoteColor, quoteColor: quoteColor,
mentionColor: mentionColor,
), ),
), ),
], ],
@@ -53,7 +71,7 @@ class FormattedMessageText extends StatefulWidget {
} }
class _FormattedMessageTextState extends State<FormattedMessageText> { class _FormattedMessageTextState extends State<FormattedMessageText> {
final List<TapGestureRecognizer> _recognizers = []; final List<GestureRecognizer> _recognizers = [];
@override @override
void dispose() { void dispose() {
@@ -87,13 +105,113 @@ class _FormattedMessageTextState extends State<FormattedMessageText> {
return ranges; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_disposeRecognizers(); _disposeRecognizers();
final segments = segmentizeFormats(widget.text, _withAutoLinks()); final ranges = _withAutoLinks();
final baseColor = widget.style.color ?? Theme.of(context).colorScheme.onSurface; 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 barColor = baseColor.withValues(alpha: 0.4);
final quoteColor = baseColor.withValues(alpha: 0.85); final quoteColor = baseColor.withValues(alpha: 0.85);
final mentionColor = mentionTextColor(cs);
final spans = <InlineSpan>[]; final spans = <InlineSpan>[];
var prevQuote = false; var prevQuote = false;
@@ -121,6 +239,7 @@ class _FormattedMessageTextState extends State<FormattedMessageText> {
widget.style, widget.style,
segment.formats, segment.formats,
quoteColor: quoteColor, quoteColor: quoteColor,
mentionColor: mentionColor,
); );
final content = widget.text.substring(segment.start, segment.end); final content = widget.text.substring(segment.start, segment.end);
if (segment.animojiUrl != null) { if (segment.animojiUrl != null) {
@@ -153,22 +272,72 @@ class _FormattedMessageTextState extends State<FormattedMessageText> {
); );
continue; 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) { if (segment.url != null) {
final url = segment.url!; final url = segment.url!;
final recognizer = TapGestureRecognizer()
..onTap = () => openExternalUrl(context, url);
_recognizers.add(recognizer);
spans.add( 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( return Text.rich(
TextSpan(style: widget.style, children: spans), TextSpan(style: widget.style, children: spans),
textAlign: widget.textAlign, 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/poll_bubble.dart';
import 'attachment/bubbles/share_bubble.dart'; import 'attachment/bubbles/share_bubble.dart';
import 'attachment/bubbles/call_bubble.dart'; import 'attachment/bubbles/call_bubble.dart';
import 'attachment/bubbles/control_bubble.dart';
import 'attachment/bubbles/location_bubble.dart'; import 'attachment/bubbles/location_bubble.dart';
import 'attachment/bubbles/contact_bubble.dart'; import 'attachment/bubbles/contact_bubble.dart';
import 'attachment/bubbles/sticker_bubble.dart'; import 'attachment/bubbles/sticker_bubble.dart';
@@ -282,6 +283,14 @@ class MessageBubble extends StatelessWidget {
return photoCount >= 2 && !hasCaption; return photoCount >= 2 && !hasCaption;
} }
bool get _showsSenderName =>
!isMe &&
chatType == "CHAT" &&
prevMessage?.senderId != message.senderId;
bool get _stretchesTextRow =>
message.replyInfo != null || _showsSenderName;
BubbleShape _computeShape() { BubbleShape _computeShape() {
if (message.isControl) return BubbleShape.singleMiddle; if (message.isControl) return BubbleShape.singleMiddle;
@@ -589,10 +598,7 @@ class MessageBubble extends StatelessWidget {
showAvatarSlot && showAvatarSlot &&
chatType == "CHAT" && chatType == "CHAT" &&
nextMessage?.senderId != message.senderId; nextMessage?.senderId != message.senderId;
final showSenderName = final showSenderName = _showsSenderName;
showAvatarSlot &&
chatType == "CHAT" &&
prevMessage?.senderId != message.senderId;
final maxBubbleWidth = math.min(MediaQuery.sizeOf(context).width * 0.75, 560.0); final maxBubbleWidth = math.min(MediaQuery.sizeOf(context).width * 0.75, 560.0);
final keyboard = _inlineKeyboard; final keyboard = _inlineKeyboard;
@@ -635,51 +641,60 @@ class MessageBubble extends StatelessWidget {
final reactionsInside = contentType != MessageType.text && !reactionsUnder; final reactionsInside = contentType != MessageType.text && !reactionsUnder;
final reply = message.replyInfo; 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 bool hasCommentsFooter = onCommentsTap != null;
final EdgeInsets containerPadding = hasCommentsFooter final EdgeInsets containerPadding = hasCommentsFooter
? EdgeInsets.zero ? EdgeInsets.zero
: padding; : padding;
final Widget innerContent = Column( final Widget contentWithReactions = reactionsInside
mainAxisSize: MainAxisSize.min, ? Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
if (showSenderName) children: [bubbleContent, _reactionsBar(cs)],
_buildSenderHeader(cs, padding == EdgeInsets.zero), )
withReply( : bubbleContent;
reactionsInside
? Column( final Widget? senderHeader = showSenderName
mainAxisSize: MainAxisSize.min, ? _buildSenderHeader(cs, padding == EdgeInsets.zero)
crossAxisAlignment: CrossAxisAlignment.start, : null;
children: [bubbleContent, _reactionsBar(cs)],
) final Widget innerContent =
: bubbleContent, 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( final Widget bubbleBox = ListenableBuilder(
listenable: Listenable.merge([ listenable: Listenable.merge([
@@ -1199,66 +1214,12 @@ class MessageBubble extends StatelessWidget {
); );
} }
Widget _buildControlContent(ColorScheme cs) { Widget _buildControlContent(ColorScheme cs) => ControlBubble(
final attachments = message.attachments; key: ValueKey('control_${message.id}'),
if (attachments == null || attachments.isEmpty) { message: message,
return const SizedBox.shrink(); cs: cs,
} onUserTap: onAvatarTap,
);
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 _wrapSelectable(Widget textWidget) { Widget _wrapSelectable(Widget textWidget) {
final listenable = textSelection; final listenable = textSelection;
@@ -1366,7 +1327,9 @@ class MessageBubble extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Flexible(child: textWidget), _stretchesTextRow
? Expanded(child: textWidget)
: Flexible(child: textWidget),
const SizedBox(width: 8), const SizedBox(width: 8),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
+36 -12
View File
@@ -121,7 +121,10 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_items = _localItems(); _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); _controller = PageController(initialPage: _index);
unawaited(_loadFeed()); unawaited(_loadFeed());
} }
@@ -135,7 +138,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
List<_ViewerPhoto> _localItems() { List<_ViewerPhoto> _localItems() {
final message = widget.message; final message = widget.message;
return [ return [
for (var i = 0; i < widget.photos.length; i++) for (var i = widget.photos.length - 1; i >= 0; i--)
_ViewerPhoto( _ViewerPhoto(
id: _localId(widget.photos[i], message, i), id: _localId(widget.photos[i], message, i),
photo: widget.photos[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) { String _localId(PhotoAttachment photo, CachedMessage? message, int at) {
final key = _feedKey(photo, message); final key = _feedKey(photo, message);
return key ?? 'local:${message?.id ?? ''}:$at'; return key ?? 'local:${message?.id ?? ''}:$at';
@@ -182,7 +202,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
return; return;
} }
final items = feed.items.map(_ViewerPhoto.fromFeed).toList(); final items = _feedItems(feed.items);
final at = items.indexWhere((i) => i.id == key); final at = items.indexWhere((i) => i.id == key);
if (at == -1) { if (at == -1) {
setState(() => _feedFailed = true); setState(() => _feedFailed = true);
@@ -224,7 +244,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
); );
if (!mounted) return; 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); final at = items.indexWhere((i) => i.id == _current.id);
if (at == -1) { if (at == -1) {
setState(() { setState(() {
@@ -413,8 +433,8 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: CallbackShortcuts( body: CallbackShortcuts(
bindings: { bindings: {
const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _step(-1), const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _step(1),
const SingleActivator(LogicalKeyboardKey.arrowRight): () => _step(1), const SingleActivator(LogicalKeyboardKey.arrowRight): () => _step(-1),
}, },
child: Focus( child: Focus(
autofocus: true, autofocus: true,
@@ -424,6 +444,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
child: PageView.builder( child: PageView.builder(
key: ValueKey(_pager), key: ValueKey(_pager),
controller: _controller, controller: _controller,
reverse: true,
itemCount: _items.length, itemCount: _items.length,
onPageChanged: _onPageChanged, onPageChanged: _onPageChanged,
itemBuilder: (_, i) => GestureDetector( itemBuilder: (_, i) => GestureDetector(
@@ -451,15 +472,18 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
curve: Curves.easeOut, curve: Curves.easeOut,
child: Stack( child: Stack(
children: [ children: [
if (_index > 0)
Align(
alignment: Alignment.centerLeft,
child: _arrow(Symbols.chevron_left, () => _step(-1)),
),
if (_index < _items.length - 1) if (_index < _items.length - 1)
Align(
alignment: Alignment.centerLeft,
child: _arrow(Symbols.chevron_left, () => _step(1)),
),
if (_index > 0)
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: _arrow(Symbols.chevron_right, () => _step(1)), child: _arrow(
Symbols.chevron_right,
() => _step(-1),
),
), ),
Positioned( Positioned(
top: padding.top + 8, 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 '../../core/utils/text_format.dart';
import '../../models/animoji.dart'; import '../../models/animoji.dart';
import 'formatted_message_text.dart';
import 'lottie_image.dart'; import 'lottie_image.dart';
const List<TextFormat> composerFormats = [ const List<TextFormat> composerFormats = [
@@ -18,6 +19,13 @@ class _Interval {
_Interval(this.start, this.end); _Interval(this.start, this.end);
} }
class _MentionEntity {
int start;
int end;
final int userId;
_MentionEntity(this.start, this.end, this.userId);
}
class _AnimojiEntity { class _AnimojiEntity {
final int uid; final int uid;
int offset; int offset;
@@ -39,6 +47,7 @@ class RichMessageController extends TextEditingController {
final Map<TextFormat, List<_Interval>> _intervals = {}; final Map<TextFormat, List<_Interval>> _intervals = {};
final List<_AnimojiEntity> _animoji = []; final List<_AnimojiEntity> _animoji = [];
final List<_MentionEntity> _mentions = [];
int _entitySeq = 0; int _entitySeq = 0;
RichMessageController({super.text}); RichMessageController({super.text});
@@ -73,6 +82,27 @@ class RichMessageController extends TextEditingController {
notifyListeners(); 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() { ({String text, List<Map<String, dynamic>> elements}) buildContent() {
final src = value.text; final src = value.text;
if (_animoji.isEmpty) { if (_animoji.isEmpty) {
@@ -121,6 +151,7 @@ class RichMessageController extends TextEditingController {
'type': textFormatToServer(range.format), 'type': textFormatToServer(range.format),
'from': from, 'from': from,
'length': to - from, 'length': to - from,
if (range.entityId != null) 'entityId': range.entityId,
}); });
} }
return (text: glyphText, elements: elements); return (text: glyphText, elements: elements);
@@ -136,7 +167,8 @@ class RichMessageController extends TextEditingController {
super.value = newValue; 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() { void clearFormatting() {
if (_intervals.isEmpty) return; if (_intervals.isEmpty) return;
@@ -146,12 +178,21 @@ class RichMessageController extends TextEditingController {
void setFormatRanges(Iterable<FormatRange> ranges) { void setFormatRanges(Iterable<FormatRange> ranges) {
_intervals.clear(); _intervals.clear();
_mentions.clear();
for (final range in ranges) { 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; if (!composerFormats.contains(range.format)) continue;
_intervals _intervals
.putIfAbsent(range.format, () => []) .putIfAbsent(range.format, () => [])
.add(_Interval(range.start, range.end)); .add(_Interval(range.start, range.end));
} }
_mentions.sort((a, b) => a.start.compareTo(b.start));
for (final list in _intervals.values) { for (final list in _intervals.values) {
_normalize(list); _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; return ranges;
} }
@@ -200,7 +251,7 @@ class RichMessageController extends TextEditingController {
} }
void _remap(String oldText, String newText) { 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 oldLen = oldText.length;
final newLen = newText.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>[]; final empty = <TextFormat>[];
_intervals.forEach((format, list) { _intervals.forEach((format, list) {
for (final interval in list) { for (final interval in list) {
@@ -325,6 +386,7 @@ class RichMessageController extends TextEditingController {
final ranges = _toFormatRanges(); final ranges = _toFormatRanges();
final baseColor = baseStyle.color; final baseColor = baseStyle.color;
final quoteColor = baseColor?.withValues(alpha: 0.85); final quoteColor = baseColor?.withValues(alpha: 0.85);
final mentionColor = mentionTextColor(Theme.of(context).colorScheme);
final segments = segmentizeFormats(content, ranges); final segments = segmentizeFormats(content, ranges);
final entityByOffset = {for (final e in _animoji) e.offset: e}; final entityByOffset = {for (final e in _animoji) e.offset: e};
final box = (baseStyle.fontSize ?? 16) * 1.4; final box = (baseStyle.fontSize ?? 16) * 1.4;
@@ -335,6 +397,7 @@ class RichMessageController extends TextEditingController {
baseStyle, baseStyle,
segment.formats, segment.formats,
quoteColor: quoteColor, quoteColor: quoteColor,
mentionColor: mentionColor,
); );
var runStart = segment.start; var runStart = segment.start;
var i = segment.start; var i = segment.start;
+1 -1
View File
@@ -3,7 +3,7 @@ import 'dart:ui';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'rightward_drag_recognizer.dart'; import 'directional_drag_recognizer.dart';
class SwipeRoute<T> extends PageRoute<T> { class SwipeRoute<T> extends PageRoute<T> {
SwipeRoute({ SwipeRoute({
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'rightward_drag_recognizer.dart'; import 'directional_drag_recognizer.dart';
class SwipeToPop extends StatefulWidget { class SwipeToPop extends StatefulWidget {
final Widget child; 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 { String? get label {
final n = name; final n = name;
if (n != null && n.trim().isNotEmpty) return n.trim(); if (n != null && n.trim().isNotEmpty) return n.trim();
return fullName;
}
String? get fullName {
final combined = [firstName, lastName] final combined = [firstName, lastName]
.where((s) => s != null && s.trim().isNotEmpty) .where((s) => s != null && s.trim().isNotEmpty)
.map((s) => s!.trim()) .map((s) => s!.trim())
.join(' '); .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; 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 { String? get firstName {
for (final n in names) { for (final n in names) {
final f = n.firstName; final f = n.firstName;
+187
View File
@@ -0,0 +1,187 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/core/utils/text_format.dart';
import 'package:komet/models/contact_info.dart';
import 'package:komet/frontend/screens/chats/chat/mention_panel_controller.dart';
import 'package:komet/frontend/widgets/rich_message_controller.dart';
void main() {
group('mentionQueryAt', () {
test('detects a bare @ at the start', () {
final q = mentionQueryAt('@', 1)!;
expect(q.start, 0);
expect(q.end, 1);
expect(q.text, '');
});
test('detects a query after a space', () {
final q = mentionQueryAt('hi @ал', 6)!;
expect(q.start, 3);
expect(q.end, 6);
expect(q.text, 'ал');
});
test('ignores an @ glued to a preceding word', () {
expect(mentionQueryAt('mail@ya', 7), isNull);
});
test('ignores a token that already contains a space', () {
expect(mentionQueryAt('@ал ексей', 9), isNull);
});
test('ignores text without an @ before the caret', () {
expect(mentionQueryAt('привет', 6), isNull);
});
});
group('RichMessageController mentions', () {
test('insertMention replaces the token and emits USER_MENTION', () {
final c = RichMessageController();
c.value = const TextEditingValue(
text: '@ал',
selection: TextSelection.collapsed(offset: 3),
);
final query = mentionQueryAt(c.text, 3)!;
c.insertMention(
userId: 3079465,
name: 'Алексей Поляков',
start: query.start,
end: query.end,
);
c.value = TextEditingValue(
text: '${c.text}test',
selection: TextSelection.collapsed(offset: c.text.length + 4),
);
final content = c.buildContent();
expect(content.text, 'Алексей Поляков test');
expect(content.elements, [
{'type': 'USER_MENTION', 'from': 0, 'length': 15, 'entityId': 3079465},
]);
});
test('editing inside a mention drops it', () {
final c = RichMessageController();
c.value = const TextEditingValue(
text: '@a',
selection: TextSelection.collapsed(offset: 2),
);
c.insertMention(userId: 42, name: 'Иван', start: 0, end: 2);
expect(c.buildContent().elements, hasLength(1));
c.value = const TextEditingValue(
text: 'Ив ',
selection: TextSelection.collapsed(offset: 2),
);
expect(c.buildContent().elements, isEmpty);
});
test('text typed before a mention shifts its offset', () {
final c = RichMessageController();
c.value = const TextEditingValue(
text: '@a',
selection: TextSelection.collapsed(offset: 2),
);
c.insertMention(userId: 42, name: 'Иван', start: 0, end: 2);
c.value = const TextEditingValue(
text: 'эй, Иван ',
selection: TextSelection.collapsed(offset: 4),
);
final element = c.buildContent().elements.single;
expect(element['from'], 4);
expect(element['length'], 4);
expect(element['entityId'], 42);
});
test('setFormatRanges restores mentions for editing', () {
final c = RichMessageController(text: 'Иван привет');
c.setFormatRanges(const [
FormatRange(
format: TextFormat.userMention,
start: 0,
length: 4,
entityId: 42,
),
]);
expect(c.buildContent().elements, [
{'type': 'USER_MENTION', 'from': 0, 'length': 4, 'entityId': 42},
]);
});
});
group('ContactInfo names', () {
ContactInfo info(List<Map<String, dynamic>> names) =>
ContactInfo.fromMap({'id': 1, 'names': names});
test('full name joins first and last, not the short name field', () {
final contact = info([
{
'name': 'Светлана',
'firstName': 'Светлана',
'lastName': 'Михайловна',
'type': 'CUSTOM',
},
{
'name': 'Светлана',
'firstName': 'Светлана',
'lastName': '',
'type': 'ONEME',
},
]);
expect(contact.fullName, 'Светлана Михайловна');
expect(contact.isSavedContact, isTrue);
});
test('a non-contact falls back to the ONEME name', () {
final contact = info([
{
'name': 'Алексей',
'firstName': 'Алексей',
'lastName': 'Поляков',
'type': 'ONEME',
},
]);
expect(contact.fullName, 'Алексей Поляков');
expect(contact.isSavedContact, isFalse);
});
test('a custom name wins over the oneme one', () {
final contact = info([
{'firstName': 'Лёша', 'lastName': 'сосед', 'type': 'CUSTOM'},
{'firstName': 'Алексей', 'lastName': 'Поляков', 'type': 'ONEME'},
]);
expect(contact.fullName, 'Лёша сосед');
});
});
group('parseFormatElements', () {
test('reads a server USER_MENTION without an explicit from', () {
final ranges = parseFormatElements([
{'entityId': 3079465, 'type': 'USER_MENTION', 'length': 15},
]);
expect(ranges.single.format, TextFormat.userMention);
expect(ranges.single.start, 0);
expect(ranges.single.length, 15);
expect(ranges.single.entityId, 3079465);
});
test('segmentizeFormats carries the mention id onto its segment', () {
final segments = segmentizeFormats('Алексей Поляков test', const [
FormatRange(
format: TextFormat.userMention,
start: 0,
length: 15,
entityId: 3079465,
),
]);
expect(segments.first.mentionId, 3079465);
expect(segments.last.mentionId, isNull);
});
});
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/backend/modules/messages.dart';
import 'package:komet/frontend/widgets/message_bubble.dart';
import 'package:komet/l10n/app_localizations.dart';
const int _me = 1;
const int _peer = 7;
CachedMessage _message({
required String text,
bool withReply = false,
int senderId = _peer,
}) => CachedMessage(
id: '1',
accountId: _me,
chatId: 2,
senderId: senderId,
text: text,
time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch,
status: 'sent',
payload: withReply
? {
'link': {
'type': 'REPLY',
'message': {
'id': '9',
'sender': _me,
'text': 'Алексей Поляков написал очень длинный ответ',
'time': 0,
'attaches': [],
},
},
}
: null,
);
Future<void> _pumpBubble(WidgetTester tester, CachedMessage message) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2.5;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Align(
alignment: Alignment.topLeft,
child: MessageBubble(
message: message,
isMe: false,
myId: _me,
chatType: 'CHAT',
),
),
),
),
);
await tester.pump();
}
Rect _rectOf(WidgetTester tester, Finder finder) {
final size = tester.getSize(finder);
final topLeft = tester.getTopLeft(finder);
return topLeft & size;
}
void main() {
setUp(() => ContactCache.put(_peer, 'Алексей Поляков123'));
testWidgets('a long sender name pushes the clock to the bubble edge', (
tester,
) async {
await _pumpBubble(tester, _message(text: 'нет'));
final header = _rectOf(tester, find.text('Алексей Поляков123'));
final clock = _rectOf(tester, find.textContaining('05:46'));
final body = _rectOf(tester, find.text('нет'));
expect(header.width, greaterThan(body.width + clock.width));
expect(clock.right, closeTo(header.right, 1));
});
testWidgets('the reply quote fills the width the sender name opened up', (
tester,
) async {
await _pumpBubble(tester, _message(text: 'нет', withReply: true));
final header = _rectOf(tester, find.text('Алексей Поляков123'));
final label = _rectOf(tester, find.text('Вы'));
final quote = _rectOf(
tester,
find
.ancestor(of: find.text('Вы'), matching: find.byType(Container))
.first,
);
final clock = _rectOf(tester, find.textContaining('05:46'));
expect(quote.right, greaterThan(label.right));
expect(quote.right, closeTo(header.right, 1));
expect(clock.right, closeTo(header.right, 1));
});
testWidgets('a bubble without a header or reply still hugs its text', (
tester,
) async {
await _pumpBubble(tester, _message(text: 'нет', senderId: 404));
final clock = _rectOf(tester, find.textContaining('05:46'));
final body = _rectOf(tester, find.text('нет'));
expect(clock.left, closeTo(body.right + 8, 1));
});
}
+85
View File
@@ -0,0 +1,85 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/core/utils/text_entities.dart';
void main() {
group('detectTextEntities', () {
test('finds a phone and a card in one message', () {
final found = detectTextEntities('+79231234567 тест 2200123456789019');
expect(found, hasLength(2));
expect(found.first.kind, TextEntityKind.phone);
expect(found.first.value, '+79231234567');
expect(found.last.kind, TextEntityKind.card);
expect(found.last.value, '2200123456789019');
});
test('finds a bare russian phone and a spaced card', () {
final found = detectTextEntities('89231234567 и 2200 1234 5678 9019');
expect(found.map((e) => e.kind), [
TextEntityKind.phone,
TextEntityKind.card,
]);
expect(found.first.value, '+89231234567');
expect(found.last.value, '2200123456789019');
});
test('ignores digits that are not a valid card', () {
expect(detectTextEntities('116984447620359334'), isEmpty);
expect(detectTextEntities('2200123456789018'), isEmpty);
expect(detectTextEntities('1234567890123456'), isEmpty);
});
test('ignores timestamps and short numbers', () {
expect(detectTextEntities('05:46:16 1785041009832'), isEmpty);
});
test('finds a nickname but not an email', () {
final found = detectTextEntities('привет @GroupGuardBot и mail@ya.ru');
expect(found, hasLength(1));
expect(found.single.kind, TextEntityKind.mention);
expect(found.single.value, 'GroupGuardBot');
expect(found.single.start, 7);
expect(found.single.end, 21);
});
test('finds a formatted profile phone', () {
final found = detectTextEntities('+7 (923) 123-45-67');
expect(found, hasLength(1));
expect(found.single.kind, TextEntityKind.phone);
expect(found.single.value, '+79231234567');
});
test('skips ranges that are already claimed', () {
const text = 'https://max.ru/GroupGuardBot';
expect(
detectTextEntities(text, skip: [(start: 0, end: text.length)]),
isEmpty,
);
});
});
group('card metadata', () {
test('recognises payment systems by BIN', () {
expect(cardBrand('2200123456789019'), 'MIR');
expect(cardBrand('4111111111111111'), 'VISA');
expect(cardBrand('5500000000000004'), 'MASTERCARD');
expect(cardBrand('340000000000009'), 'AMEX');
expect(cardBrand('6200000000000005'), 'UNIONPAY');
expect(cardBrand('1234567890123456'), isNull);
});
test('builds the mask shown in the action menu', () {
expect(cardMask('2200123456789019'), 'MIR*9019');
expect(cardBrandTitle('2200123456789019'), 'МИР');
});
test('formats a card number in groups of four', () {
expect(formatCardNumber('2200123456789019'), '2200 1234 5678 9019');
});
test('luhn rejects a corrupted number', () {
expect(isLuhnValid('2200123456789019'), isTrue);
expect(isLuhnValid('2200123456789018'), isFalse);
});
});
}
+204
View File
@@ -0,0 +1,204 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/backend/modules/messages.dart';
import 'package:komet/frontend/widgets/formatted_message_text.dart';
import 'package:komet/frontend/widgets/message_bubble.dart';
import 'package:komet/frontend/widgets/text_entity_actions.dart';
import 'package:komet/l10n/app_localizations.dart';
const String _sample = '+79231234567 тест 2200123456789019 @GroupGuardBot';
CachedMessage _message(String text) => CachedMessage(
id: '1',
accountId: 1,
chatId: 2,
senderId: 1,
text: text,
time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch,
status: 'sent',
);
Future<void> _pump(WidgetTester tester, Widget child) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2.5;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Align(alignment: Alignment.topLeft, child: child),
),
),
);
await tester.pump();
}
TextSpan? _spanWithText(WidgetTester tester, String text) {
TextSpan? found;
for (final widget in tester.widgetList<RichText>(find.byType(RichText))) {
widget.text.visitChildren((span) {
if (span is TextSpan && span.text == text) {
found = span;
return false;
}
return true;
});
if (found != null) break;
}
return found;
}
void main() {
testWidgets('a bubble highlights the phone, the card and the nickname', (
tester,
) async {
await _pump(
tester,
MessageBubble(
message: _message(_sample),
isMe: false,
myId: 1,
chatType: 'DIALOG',
),
);
final accent = ThemeData().colorScheme.primary;
final phone = _spanWithText(tester, '+79231234567');
final card = _spanWithText(tester, '2200123456789019');
final mention = _spanWithText(tester, '@GroupGuardBot');
final plain = _spanWithText(tester, ' тест ');
expect(phone?.style?.color, accent);
expect(card?.style?.color, accent);
expect(mention?.style?.color, accent);
expect(plain?.style?.color, isNot(accent));
expect(phone?.recognizer, isA<LongPressGestureRecognizer>());
expect(card?.recognizer, isA<LongPressGestureRecognizer>());
expect(mention?.recognizer, isA<TapGestureRecognizer>());
});
testWidgets('a server USER_MENTION by name opens the profile on tap', (
tester,
) async {
final message = CachedMessage(
id: '2',
accountId: 1,
chatId: 2,
senderId: 1,
text: '@GroupGuardBot test',
time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch,
status: 'sent',
payload: const {
'elements': [
{'entityName': 'GroupGuardBot', 'type': 'USER_MENTION', 'length': 14},
],
},
);
await _pump(
tester,
MessageBubble(message: message, isMe: false, myId: 1, chatType: 'DIALOG'),
);
final mention = _spanWithText(tester, '@GroupGuardBot');
expect(mention?.style?.color, ThemeData().colorScheme.primary);
expect(mention?.recognizer, isA<TapGestureRecognizer>());
});
testWidgets('copy mode taps instead of opening a menu', (tester) async {
await _pump(
tester,
FormattedMessageText(
text: _sample,
ranges: const [],
entityMode: TextEntityMode.copy,
style: const TextStyle(fontSize: 16),
),
);
expect(
_spanWithText(tester, '+79231234567')?.recognizer,
isA<TapGestureRecognizer>(),
);
expect(
_spanWithText(tester, '2200123456789019')?.recognizer,
isA<TapGestureRecognizer>(),
);
});
Future<void> openMenuAt(WidgetTester tester, Offset at) async {
await _pump(
tester,
Builder(
builder: (context) => TextButton(
onPressed: () =>
showCardEntityMenu(context, '2200123456789019', at: at),
child: const Text('open'),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
Rect menuRect(WidgetTester tester) => tester.getRect(
find
.ancestor(
of: find.text('Скопировать номер карты'),
matching: find.byType(SingleChildScrollView),
)
.first,
);
testWidgets('a menu opened near the bottom flips above the anchor', (
tester,
) async {
await openMenuAt(tester, const Offset(200, 940));
final screen = tester.view.physicalSize / tester.view.devicePixelRatio;
final rect = menuRect(tester);
expect(rect.bottom, lessThanOrEqualTo(screen.height - 8));
expect(rect.bottom, lessThan(940));
expect(rect.top, greaterThanOrEqualTo(8));
});
testWidgets('a menu opened near the top stays below the anchor', (
tester,
) async {
await openMenuAt(tester, const Offset(200, 100));
final rect = menuRect(tester);
expect(rect.top, greaterThanOrEqualTo(100));
});
testWidgets('the card menu shows the copy action and the card mask', (
tester,
) async {
await _pump(
tester,
Builder(
builder: (context) => TextButton(
onPressed: () => showCardEntityMenu(
context,
'2200123456789019',
at: const Offset(200, 300),
),
child: const Text('open'),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('Скопировать номер карты'), findsOneWidget);
expect(find.text('MIR*9019'), findsOneWidget);
expect(find.text('МИР'), findsOneWidget);
});
}