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