feat: работа с текстом
This commit is contained in:
@@ -307,18 +307,22 @@ class ChatSearchHit {
|
||||
class ChatMemberEntry {
|
||||
final int id;
|
||||
final String? name;
|
||||
final String? fullName;
|
||||
final String? avatarUrl;
|
||||
final int? seenTime;
|
||||
final int presenceStatus;
|
||||
final bool blocked;
|
||||
final bool isContact;
|
||||
|
||||
const ChatMemberEntry({
|
||||
required this.id,
|
||||
this.name,
|
||||
this.fullName,
|
||||
this.avatarUrl,
|
||||
this.seenTime,
|
||||
required this.presenceStatus,
|
||||
this.blocked = false,
|
||||
this.isContact = false,
|
||||
});
|
||||
|
||||
bool get isOnline => presenceStatus == 1;
|
||||
@@ -1865,10 +1869,12 @@ class ChatsModule {
|
||||
ChatMemberEntry(
|
||||
id: id,
|
||||
name: name,
|
||||
fullName: info.fullName,
|
||||
avatarUrl: avatar,
|
||||
seenTime: seen,
|
||||
presenceStatus: status,
|
||||
blocked: info.isDeleted,
|
||||
isContact: info.isSavedContact,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,12 +94,21 @@ class AddContactResult {
|
||||
class ContactsModule {
|
||||
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
|
||||
|
||||
static Future<PhoneLookupResult?> findByPhone(Api api, String phone) async {
|
||||
static Future<PhoneLookupResult?> findByPhone(
|
||||
Api api,
|
||||
String phone, {
|
||||
bool silent = false,
|
||||
}) async {
|
||||
final normalized = _normalizePhone(phone);
|
||||
if (normalized == null) return null;
|
||||
final packet = await api.sendRequest(Opcode.contactInfoByPhone, {
|
||||
'phone': normalized,
|
||||
});
|
||||
final Packet packet;
|
||||
try {
|
||||
packet = await api.sendRequest(Opcode.contactInfoByPhone, {
|
||||
'phone': normalized,
|
||||
}, silent: silent);
|
||||
} on PacketError {
|
||||
return null;
|
||||
}
|
||||
if (packet.isError) return null;
|
||||
final contact = (packet.payload as Map?)?['contact'];
|
||||
if (contact is! Map) return null;
|
||||
|
||||
@@ -31,7 +31,9 @@ abstract class LinkModule {
|
||||
static Future<ResolvedLink?> resolve(Api api, String url) async {
|
||||
final Packet response;
|
||||
try {
|
||||
response = await api.sendRequest(Opcode.linkInfo, {'link': url});
|
||||
response = await api.sendRequest(Opcode.linkInfo, {
|
||||
'link': url,
|
||||
}, silent: true);
|
||||
} on TimeoutException {
|
||||
return const ResolvedLinkError('Превышено время ожидания');
|
||||
} on PacketError catch (e) {
|
||||
|
||||
@@ -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,
|
||||
link,
|
||||
animoji,
|
||||
userMention,
|
||||
}
|
||||
|
||||
const Map<TextFormat, String> _formatToServer = {
|
||||
@@ -20,6 +21,7 @@ const Map<TextFormat, String> _formatToServer = {
|
||||
TextFormat.quote: 'QUOTE',
|
||||
TextFormat.link: 'LINK',
|
||||
TextFormat.animoji: 'ANIMOJI',
|
||||
TextFormat.userMention: 'USER_MENTION',
|
||||
};
|
||||
|
||||
final Map<String, TextFormat> _serverToFormat = {
|
||||
@@ -35,12 +37,16 @@ class FormatRange {
|
||||
final TextFormat format;
|
||||
final int start;
|
||||
final int length;
|
||||
final int? entityId;
|
||||
final String? entityName;
|
||||
final Map<String, dynamic>? attributes;
|
||||
|
||||
const FormatRange({
|
||||
required this.format,
|
||||
required this.start,
|
||||
required this.length,
|
||||
this.entityId,
|
||||
this.entityName,
|
||||
this.attributes,
|
||||
});
|
||||
|
||||
@@ -60,6 +66,8 @@ class FormatRange {
|
||||
'type': textFormatToServer(format),
|
||||
'from': start,
|
||||
'length': length,
|
||||
if (entityId != null) 'entityId': entityId,
|
||||
if (entityName != null) 'entityName': entityName,
|
||||
if (attributes != null) 'attributes': attributes,
|
||||
};
|
||||
}
|
||||
@@ -78,11 +86,17 @@ List<FormatRange> parseFormatElements(dynamic raw) {
|
||||
final attributes = attrsRaw is Map
|
||||
? Map<String, dynamic>.from(attrsRaw)
|
||||
: null;
|
||||
final entityId = item['entityId'];
|
||||
final entityName = item['entityName'];
|
||||
result.add(
|
||||
FormatRange(
|
||||
format: format,
|
||||
start: from,
|
||||
length: length,
|
||||
entityId: entityId is int ? entityId : null,
|
||||
entityName: entityName is String && entityName.isNotEmpty
|
||||
? entityName
|
||||
: null,
|
||||
attributes: attributes,
|
||||
),
|
||||
);
|
||||
@@ -134,6 +148,8 @@ class FormatSegment {
|
||||
final Set<TextFormat> formats;
|
||||
final String? url;
|
||||
final String? animojiUrl;
|
||||
final int? mentionId;
|
||||
final String? mentionName;
|
||||
|
||||
const FormatSegment({
|
||||
required this.start,
|
||||
@@ -141,6 +157,8 @@ class FormatSegment {
|
||||
required this.formats,
|
||||
this.url,
|
||||
this.animojiUrl,
|
||||
this.mentionId,
|
||||
this.mentionName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,6 +175,8 @@ List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
|
||||
format: range.format,
|
||||
start: start,
|
||||
length: end - start,
|
||||
entityId: range.entityId,
|
||||
entityName: range.entityName,
|
||||
attributes: range.attributes,
|
||||
),
|
||||
);
|
||||
@@ -180,11 +200,17 @@ List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
|
||||
final formats = <TextFormat>{};
|
||||
String? url;
|
||||
String? animojiUrl;
|
||||
int? mentionId;
|
||||
String? mentionName;
|
||||
for (final range in clamped) {
|
||||
if (range.start <= start && range.end >= end) {
|
||||
formats.add(range.format);
|
||||
if (range.format == TextFormat.link) url ??= range.url;
|
||||
if (range.format == TextFormat.animoji) animojiUrl ??= range.animojiUrl;
|
||||
if (range.format == TextFormat.userMention) {
|
||||
mentionId ??= range.entityId;
|
||||
mentionName ??= range.entityName;
|
||||
}
|
||||
}
|
||||
}
|
||||
segments.add(
|
||||
@@ -194,6 +220,8 @@ List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
|
||||
formats: formats,
|
||||
url: url,
|
||||
animojiUrl: animojiUrl,
|
||||
mentionId: mentionId,
|
||||
mentionName: mentionName,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -204,6 +232,7 @@ TextStyle applyTextFormats(
|
||||
TextStyle base,
|
||||
Set<TextFormat> formats, {
|
||||
Color? quoteColor,
|
||||
Color? mentionColor,
|
||||
}) {
|
||||
if (formats.isEmpty) return base;
|
||||
|
||||
@@ -219,11 +248,15 @@ TextStyle applyTextFormats(
|
||||
final isItalic = formats.contains(TextFormat.emphasized) ||
|
||||
formats.contains(TextFormat.quote);
|
||||
|
||||
final isMention = formats.contains(TextFormat.userMention);
|
||||
|
||||
return base.copyWith(
|
||||
fontWeight: formats.contains(TextFormat.strong) ? FontWeight.w700 : null,
|
||||
fontStyle: isItalic ? FontStyle.italic : null,
|
||||
fontFamily: formats.contains(TextFormat.monospaced) ? 'monospace' : null,
|
||||
color: formats.contains(TextFormat.quote) ? quoteColor : null,
|
||||
color: isMention
|
||||
? mentionColor
|
||||
: (formats.contains(TextFormat.quote) ? quoteColor : null),
|
||||
decoration: decorations.isEmpty
|
||||
? null
|
||||
: TextDecoration.combine(decorations),
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../../core/calls/call_controller.dart';
|
||||
import '../../../backend/modules/calls.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/reload_on_reconnect.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/chat_menu_overlay.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
@@ -22,7 +23,7 @@ class CallsTab extends StatefulWidget {
|
||||
State<CallsTab> createState() => _CallsTabState();
|
||||
}
|
||||
|
||||
class _CallsTabState extends State<CallsTab> {
|
||||
class _CallsTabState extends State<CallsTab> with ReloadOnReconnect {
|
||||
List<CallLogEntry> _calls = [];
|
||||
final Set<String> _removing = {};
|
||||
bool _isLoading = true;
|
||||
@@ -51,6 +52,11 @@ class _CallsTabState extends State<CallsTab> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void reloadAfterReconnect() {
|
||||
if (accountModule.isLoggedIn) _loadHistory();
|
||||
}
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
final p = await AppDatabase.loadActiveProfile();
|
||||
if (p == null) {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../../../backend/modules/chats.dart' show chats;
|
||||
import '../../../../main.dart' show api;
|
||||
|
||||
class MentionCandidate {
|
||||
final int id;
|
||||
final String name;
|
||||
final String? avatarUrl;
|
||||
final bool isContact;
|
||||
|
||||
const MentionCandidate({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.avatarUrl,
|
||||
this.isContact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MentionCandidate &&
|
||||
other.id == id &&
|
||||
other.name == name &&
|
||||
other.isContact == isContact;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, name, isContact);
|
||||
}
|
||||
|
||||
class MentionQuery {
|
||||
final int start;
|
||||
final int end;
|
||||
final String text;
|
||||
|
||||
const MentionQuery({
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.text,
|
||||
});
|
||||
}
|
||||
|
||||
MentionQuery? mentionQueryAt(String text, int cursor) {
|
||||
if (cursor <= 0 || cursor > text.length) return null;
|
||||
const maxQueryLength = 32;
|
||||
|
||||
var index = cursor - 1;
|
||||
while (index >= 0) {
|
||||
final code = text.codeUnitAt(index);
|
||||
if (code == 0x40) break;
|
||||
if (code == 0x20 || code == 0x0A || code == 0x09) return null;
|
||||
if (cursor - index > maxQueryLength) return null;
|
||||
index--;
|
||||
}
|
||||
if (index < 0) return null;
|
||||
if (index > 0) {
|
||||
final before = text.codeUnitAt(index - 1);
|
||||
if (before != 0x20 && before != 0x0A && before != 0x09) return null;
|
||||
}
|
||||
|
||||
return MentionQuery(
|
||||
start: index,
|
||||
end: cursor,
|
||||
text: text.substring(index + 1, cursor),
|
||||
);
|
||||
}
|
||||
|
||||
class MentionPanelController {
|
||||
MentionPanelController({
|
||||
required TickerProvider vsync,
|
||||
required this.chatId,
|
||||
required this.enabled,
|
||||
required this.selfId,
|
||||
required this.valueOf,
|
||||
required this.onSelected,
|
||||
}) {
|
||||
anim = AnimationController(
|
||||
vsync: vsync,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
}
|
||||
|
||||
static const int _pageSize = 50;
|
||||
static const int _desiredMatches = 30;
|
||||
static const int _autoFetchLimit = 300;
|
||||
|
||||
final int chatId;
|
||||
final bool Function() enabled;
|
||||
final int Function() selfId;
|
||||
final TextEditingValue Function() valueOf;
|
||||
final void Function(MentionCandidate candidate, MentionQuery query)
|
||||
onSelected;
|
||||
|
||||
late final AnimationController anim;
|
||||
final ValueNotifier<List<MentionCandidate>> matches = ValueNotifier(const []);
|
||||
final ValueNotifier<bool> loadingMore = ValueNotifier(false);
|
||||
|
||||
final List<MentionCandidate> _members = [];
|
||||
final Set<int> _seen = {};
|
||||
int _marker = 0;
|
||||
bool _end = false;
|
||||
bool _fetching = false;
|
||||
bool _visible = false;
|
||||
MentionQuery? _query;
|
||||
|
||||
bool get hasMore => !_end;
|
||||
|
||||
void update() {
|
||||
final query = enabled() ? _queryAt(valueOf()) : null;
|
||||
_query = query;
|
||||
|
||||
if (query == null) {
|
||||
_setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_members.isEmpty && !_end) unawaited(_fetchPage());
|
||||
|
||||
final found = _match(query.text);
|
||||
if (!listEquals(matches.value, found)) matches.value = found;
|
||||
if (found.length < _desiredMatches && _members.length < _autoFetchLimit) {
|
||||
unawaited(_fetchPage());
|
||||
}
|
||||
|
||||
_setVisible(found.isNotEmpty || (_members.isEmpty && !_end));
|
||||
}
|
||||
|
||||
void select(MentionCandidate candidate) {
|
||||
final query = _query;
|
||||
if (query == null) return;
|
||||
onSelected(candidate, query);
|
||||
}
|
||||
|
||||
Future<void> loadMore() => _fetchPage();
|
||||
|
||||
MentionQuery? _queryAt(TextEditingValue value) {
|
||||
final selection = value.selection;
|
||||
if (!selection.isValid || !selection.isCollapsed) return null;
|
||||
return mentionQueryAt(value.text, selection.baseOffset);
|
||||
}
|
||||
|
||||
List<MentionCandidate> _match(String raw) {
|
||||
final me = selfId();
|
||||
final query = raw.toLowerCase().trim();
|
||||
final found = _members
|
||||
.where((c) => c.id != me)
|
||||
.where((c) => query.isEmpty || _matchesQuery(c.name, query));
|
||||
return [
|
||||
...found.where((c) => c.isContact),
|
||||
...found.where((c) => !c.isContact),
|
||||
];
|
||||
}
|
||||
|
||||
bool _matchesQuery(String name, String query) {
|
||||
final lower = name.toLowerCase();
|
||||
if (lower.startsWith(query)) return true;
|
||||
for (final word in lower.split(' ')) {
|
||||
if (word.startsWith(query)) return true;
|
||||
}
|
||||
return lower.contains(query);
|
||||
}
|
||||
|
||||
Future<void> _fetchPage() async {
|
||||
if (_fetching || _end) return;
|
||||
_fetching = true;
|
||||
loadingMore.value = true;
|
||||
try {
|
||||
final page = await chats.getChatMembers(
|
||||
api,
|
||||
chatId,
|
||||
marker: _marker,
|
||||
count: _pageSize,
|
||||
);
|
||||
if (page == null) {
|
||||
_end = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var added = 0;
|
||||
for (final member in page.members) {
|
||||
final name = member.fullName ?? member.name;
|
||||
if (name == null || name.isEmpty) continue;
|
||||
if (member.blocked) continue;
|
||||
if (!_seen.add(member.id)) continue;
|
||||
_members.add(
|
||||
MentionCandidate(
|
||||
id: member.id,
|
||||
name: name,
|
||||
avatarUrl: member.avatarUrl,
|
||||
isContact: member.isContact,
|
||||
),
|
||||
);
|
||||
added++;
|
||||
}
|
||||
|
||||
if (added == 0 || page.members.isEmpty || page.marker == _marker) {
|
||||
_end = true;
|
||||
}
|
||||
_marker = page.marker;
|
||||
|
||||
if (added > 0 && _query != null) {
|
||||
final found = _match(_query!.text);
|
||||
if (!listEquals(matches.value, found)) matches.value = found;
|
||||
_setVisible(found.isNotEmpty);
|
||||
}
|
||||
} finally {
|
||||
_fetching = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _setVisible(bool show) {
|
||||
if (show == _visible) return;
|
||||
_visible = show;
|
||||
if (show) {
|
||||
anim.forward();
|
||||
} else {
|
||||
anim.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
anim.dispose();
|
||||
matches.dispose();
|
||||
loadingMore.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:komet/frontend/screens/chats/chat/mention_panel_controller.dart';
|
||||
import 'package:komet/frontend/widgets/mention_suggestions_panel.dart';
|
||||
|
||||
class MentionPanelView extends StatelessWidget {
|
||||
const MentionPanelView({super.key, required this.mentionPanel});
|
||||
|
||||
final MentionPanelController mentionPanel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: mentionPanel.anim,
|
||||
child: ValueListenableBuilder<List<MentionCandidate>>(
|
||||
valueListenable: mentionPanel.matches,
|
||||
builder: (context, matches, _) => ValueListenableBuilder<bool>(
|
||||
valueListenable: mentionPanel.loadingMore,
|
||||
builder: (context, loading, _) => MentionSuggestionsPanel(
|
||||
candidates: matches,
|
||||
loadingMore: loading && mentionPanel.hasMore,
|
||||
onSelected: mentionPanel.select,
|
||||
onLoadMore: mentionPanel.loadMore,
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final t = mentionPanel.anim.value;
|
||||
if (t == 0) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: IgnorePointer(
|
||||
ignoring: t < 1,
|
||||
child: Opacity(opacity: t, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import '../../widgets/animated_text_swap.dart';
|
||||
import '../../widgets/avatar_history_screen.dart';
|
||||
import '../../widgets/chat_info/shared_content_tabs.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/formatted_message_text.dart';
|
||||
import '../../widgets/reload_on_reconnect.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
@@ -83,7 +85,8 @@ class ChatInfoScreen extends StatefulWidget {
|
||||
State<ChatInfoScreen> createState() => _ChatInfoScreenState();
|
||||
}
|
||||
|
||||
class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
with ReloadOnReconnect {
|
||||
final _tabScrollController = ScrollController();
|
||||
final _bodyScrollController = ScrollController();
|
||||
|
||||
@@ -175,6 +178,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void reloadAfterReconnect() => _load();
|
||||
|
||||
Future<void> _load() async {
|
||||
final profile = await AppDatabase.loadActiveProfile();
|
||||
_myId = profile?.id ?? 0;
|
||||
@@ -789,7 +795,12 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
: int.tryParse(phone?.toString() ?? '');
|
||||
if (phoneInt != null && phoneInt > 0) {
|
||||
items.add(
|
||||
_simpleInfoCard(cs, l10n.loginPhoneNumber, formatPhone(phoneInt)!),
|
||||
_simpleInfoCard(
|
||||
cs,
|
||||
l10n.loginPhoneNumber,
|
||||
formatPhone(phoneInt)!,
|
||||
entities: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
final bio =
|
||||
@@ -797,7 +808,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
(_contactData?.raw['about'] as String?);
|
||||
if (bio != null && bio.isNotEmpty) {
|
||||
if (items.isNotEmpty) items.add(const SizedBox(height: 8));
|
||||
items.add(_simpleInfoCard(cs, l10n.chatInfoBio, bio));
|
||||
items.add(_simpleInfoCard(cs, l10n.chatInfoBio, bio, entities: true));
|
||||
}
|
||||
}
|
||||
} else if (widget.chatType == 'CHANNEL') {
|
||||
@@ -824,6 +835,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
String label,
|
||||
String value, {
|
||||
bool isLink = false,
|
||||
bool entities = false,
|
||||
}) {
|
||||
return GlossyPill(
|
||||
color: cs.surfaceContainerHigh,
|
||||
@@ -840,14 +852,26 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: isLink ? cs.primary : cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
if (entities)
|
||||
FormattedMessageText(
|
||||
text: value,
|
||||
ranges: const [],
|
||||
entityMode: TextEntityMode.copy,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: isLink ? cs.primary : cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -905,8 +929,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
desc,
|
||||
FormattedMessageText(
|
||||
text: desc,
|
||||
ranges: const [],
|
||||
entityMode: TextEntityMode.copy,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4),
|
||||
maxLines: (_descExpanded || !isLong) ? null : collapsedLines,
|
||||
overflow: (_descExpanded || !isLong)
|
||||
|
||||
@@ -65,6 +65,8 @@ import 'chat/view/search_view.dart';
|
||||
import 'chat/view/composer_input.dart';
|
||||
import 'chat/view/sticker_panel_view.dart';
|
||||
import 'chat/view/command_panel_view.dart';
|
||||
import 'chat/view/mention_panel_view.dart';
|
||||
import 'chat/mention_panel_controller.dart';
|
||||
import 'chat/view/selection_bar.dart';
|
||||
import 'chat/view/chat_header.dart';
|
||||
import 'chat/view/shimmer_loading.dart';
|
||||
@@ -93,6 +95,8 @@ import '../../widgets/sticker_pack_sheet.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
import '../../widgets/swipe_to_pop.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
import '../../widgets/directional_drag_recognizer.dart';
|
||||
import '../../widgets/reload_on_reconnect.dart';
|
||||
import '../../widgets/schedule_time_picker.dart';
|
||||
import '../../widgets/chat_wallpaper_sheet.dart';
|
||||
import '../../widgets/chat_wallpaper_view.dart';
|
||||
@@ -236,7 +240,7 @@ class ChatScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen>
|
||||
with TickerProviderStateMixin, WidgetsBindingObserver {
|
||||
with TickerProviderStateMixin, WidgetsBindingObserver, ReloadOnReconnect {
|
||||
final RichMessageController _messageController = RichMessageController();
|
||||
final FocusNode _messageFocusNode = FocusNode();
|
||||
double _keyboardReserve = 0;
|
||||
@@ -454,6 +458,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
int _tempIdCounter = 0;
|
||||
late final AnimationController _attachAnim;
|
||||
late final CommandPanelController _commandPanel;
|
||||
late final MentionPanelController _mentionPanel;
|
||||
|
||||
String _nextTempId() =>
|
||||
'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
|
||||
@@ -602,6 +607,14 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
textOf: () => _messageController.text,
|
||||
onSelected: _onCommandSelected,
|
||||
);
|
||||
_mentionPanel = MentionPanelController(
|
||||
vsync: this,
|
||||
chatId: widget.chatId,
|
||||
enabled: _mentionsAvailable,
|
||||
selfId: () => _myId,
|
||||
valueOf: () => _messageController.value,
|
||||
onSelected: _onMentionSelected,
|
||||
);
|
||||
_selectionAnim = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
@@ -683,6 +696,13 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered);
|
||||
}
|
||||
|
||||
@override
|
||||
void reloadAfterReconnect() {
|
||||
if (!_historyKickedOff) return;
|
||||
unawaited(_loadHistory());
|
||||
unawaited(_loadParticipantsCount());
|
||||
}
|
||||
|
||||
Future<void> _loadParticipantsCount() async {
|
||||
if (_commentsMode) return;
|
||||
if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return;
|
||||
@@ -1857,6 +1877,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_uploadStatus.dispose();
|
||||
_attachAnim.dispose();
|
||||
_commandPanel.dispose();
|
||||
_mentionPanel.dispose();
|
||||
_selectionAnim.dispose();
|
||||
_searchAnim.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
@@ -1884,6 +1905,21 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_hasText.value = newHasText;
|
||||
}
|
||||
_commandPanel.update();
|
||||
_mentionPanel.update();
|
||||
}
|
||||
|
||||
bool _mentionsAvailable() =>
|
||||
!_commentsMode && (chat?.type ?? widget.chatType) == 'CHAT';
|
||||
|
||||
void _onMentionSelected(MentionCandidate candidate, MentionQuery query) {
|
||||
_messageController.insertMention(
|
||||
userId: candidate.id,
|
||||
name: candidate.name,
|
||||
start: query.start,
|
||||
end: query.end,
|
||||
);
|
||||
_mentionPanel.update();
|
||||
_messageFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _onCommandSelected(SlashCommand c) {
|
||||
@@ -3371,6 +3407,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
return 'Ссылка';
|
||||
case TextFormat.animoji:
|
||||
return 'Animoji';
|
||||
case TextFormat.userMention:
|
||||
return 'Упоминание';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4794,7 +4832,13 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: frosted ? height : 0,
|
||||
child: CommandPanelView(commandPanel: _commandPanel),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MentionPanelView(mentionPanel: _mentionPanel),
|
||||
CommandPanelView(commandPanel: _commandPanel),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (frosted)
|
||||
@@ -4884,7 +4928,13 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: height,
|
||||
child: CommandPanelView(commandPanel: _commandPanel),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MentionPanelView(mentionPanel: _mentionPanel),
|
||||
CommandPanelView(commandPanel: _commandPanel),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -6346,6 +6396,12 @@ class _SwipeToReplyState extends State<_SwipeToReply>
|
||||
|
||||
void _onDragEnd(DragEndDetails d) {
|
||||
if (_triggered) widget.onReply();
|
||||
_settle();
|
||||
}
|
||||
|
||||
void _onDragCancel() => _settle();
|
||||
|
||||
void _settle() {
|
||||
_triggered = false;
|
||||
_springFrom = _dragX;
|
||||
_springBack.forward(from: 0);
|
||||
@@ -6355,10 +6411,20 @@ class _SwipeToReplyState extends State<_SwipeToReply>
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final progress = (-_dragX / _triggerThreshold).clamp(0.0, 1.0);
|
||||
return GestureDetector(
|
||||
return RawGestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragUpdate: _onDragUpdate,
|
||||
onHorizontalDragEnd: _onDragEnd,
|
||||
gestures: <Type, GestureRecognizerFactory>{
|
||||
LeftwardDragRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<LeftwardDragRecognizer>(
|
||||
() => LeftwardDragRecognizer(debugOwner: this),
|
||||
(instance) {
|
||||
instance
|
||||
..onUpdate = _onDragUpdate
|
||||
..onEnd = _onDragEnd
|
||||
..onCancel = _onDragCancel;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Stack(
|
||||
alignment: Alignment.centerRight,
|
||||
children: [
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/schedule_time_picker.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
import '../../widgets/reload_on_reconnect.dart';
|
||||
|
||||
class ScheduledMessagesScreen extends StatefulWidget {
|
||||
final int chatId;
|
||||
@@ -36,7 +37,8 @@ class ScheduledMessagesScreen extends StatefulWidget {
|
||||
_ScheduledMessagesScreenState();
|
||||
}
|
||||
|
||||
class _ScheduledMessagesScreenState extends State<ScheduledMessagesScreen> {
|
||||
class _ScheduledMessagesScreenState extends State<ScheduledMessagesScreen>
|
||||
with ReloadOnReconnect {
|
||||
final List<CachedMessage> _messages = [];
|
||||
StreamSubscription<Packet>? _pushSub;
|
||||
bool _loading = true;
|
||||
@@ -61,6 +63,9 @@ class _ScheduledMessagesScreenState extends State<ScheduledMessagesScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void reloadAfterReconnect() => _load();
|
||||
|
||||
Future<void> _load() async {
|
||||
final list = await messagesModule.fetchDelayedMessages(
|
||||
widget.accountId,
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart' show digitalIdModule, webAppModule;
|
||||
import '../../../models/digital_id.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/reload_on_reconnect.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/error_view.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
@@ -41,7 +42,8 @@ class DigitalIdScreen extends StatefulWidget {
|
||||
State<DigitalIdScreen> createState() => _DigitalIdScreenState();
|
||||
}
|
||||
|
||||
class _DigitalIdScreenState extends State<DigitalIdScreen> {
|
||||
class _DigitalIdScreenState extends State<DigitalIdScreen>
|
||||
with ReloadOnReconnect {
|
||||
bool _loading = true;
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
@@ -56,6 +58,9 @@ class _DigitalIdScreenState extends State<DigitalIdScreen> {
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void reloadAfterReconnect() => _load();
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../../../core/utils/format.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/reload_on_reconnect.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
@@ -30,7 +31,7 @@ class CloudStorageScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
with SingleTickerProviderStateMixin, ReloadOnReconnect {
|
||||
static const _translateFactor = 0.7;
|
||||
static const _horizontalPadding = 32.0;
|
||||
static const _hintSidePadding = 35.0;
|
||||
@@ -185,6 +186,17 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void reloadAfterReconnect() {
|
||||
final accountId = _accountId;
|
||||
final groupId = _envGroupId;
|
||||
if (accountId == null || groupId == null) {
|
||||
_checkEnv();
|
||||
return;
|
||||
}
|
||||
unawaited(_loadFiles(accountId, groupId));
|
||||
}
|
||||
|
||||
Future<void> _loadFiles(int accountId, int chatId) async {
|
||||
final files = await CloudStorageModule.fetchFiles(
|
||||
messagesModule,
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../../main.dart' show accountModule;
|
||||
import '../../../backend/modules/account.dart' show SessionInfo;
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/reload_on_reconnect.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/prompt_dialog.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
@@ -26,7 +27,7 @@ class DevicesScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DevicesScreenState extends State<DevicesScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
with SingleTickerProviderStateMixin, ReloadOnReconnect {
|
||||
bool _isLoading = true;
|
||||
List<SessionInfo> _sessions = [];
|
||||
final Map<int, Map<String, dynamic>> _ipDetails = {};
|
||||
@@ -50,6 +51,9 @@ class _DevicesScreenState extends State<DevicesScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void reloadAfterReconnect() => _loadSessions();
|
||||
|
||||
Future<void> _loadSessions() async {
|
||||
try {
|
||||
final sessions = await accountModule.getSessions();
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../core/utils/haptics.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart' show accountModule, isOnemeFlavor;
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/reload_on_reconnect.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/section_header.dart';
|
||||
import '../../widgets/settings_card.dart';
|
||||
@@ -17,7 +18,8 @@ class NotificationsScreen extends StatefulWidget {
|
||||
State<NotificationsScreen> createState() => _NotificationsScreenState();
|
||||
}
|
||||
|
||||
class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||
class _NotificationsScreenState extends State<NotificationsScreen>
|
||||
with ReloadOnReconnect {
|
||||
bool _loading = true;
|
||||
bool _saving = false;
|
||||
|
||||
@@ -34,6 +36,9 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void reloadAfterReconnect() => _load();
|
||||
|
||||
Future<void> _load() async {
|
||||
final config = await accountModule.getPrivacyConfig();
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../../l10n/app_localizations.dart';
|
||||
import '../../widgets/confirm_dialog.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/reload_on_reconnect.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
@@ -23,7 +24,7 @@ class SecurityScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SecurityScreenState extends State<SecurityScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
with SingleTickerProviderStateMixin, ReloadOnReconnect {
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
bool _is2faEnabled = false;
|
||||
@@ -47,6 +48,9 @@ class _SecurityScreenState extends State<SecurityScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void reloadAfterReconnect() => _loadData();
|
||||
|
||||
Future<void> _loadData() async {
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../backend/modules/messages.dart';
|
||||
import '../../../../models/attachment.dart';
|
||||
|
||||
class _ControlSegment {
|
||||
final String text;
|
||||
final int? userId;
|
||||
|
||||
const _ControlSegment(this.text, [this.userId]);
|
||||
}
|
||||
|
||||
class _ControlText {
|
||||
final List<_ControlSegment> segments;
|
||||
final int? tapUserId;
|
||||
|
||||
const _ControlText(this.segments, this.tapUserId);
|
||||
}
|
||||
|
||||
class ControlBubble extends StatefulWidget {
|
||||
final CachedMessage message;
|
||||
final ColorScheme cs;
|
||||
final void Function(int userId)? onUserTap;
|
||||
|
||||
const ControlBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.cs,
|
||||
this.onUserTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ControlBubble> createState() => _ControlBubbleState();
|
||||
}
|
||||
|
||||
class _ControlBubbleState extends State<ControlBubble> {
|
||||
final Map<int, TapGestureRecognizer> _recognizers = {};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final recognizer in _recognizers.values) {
|
||||
recognizer.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
TapGestureRecognizer _recognizerFor(int userId) => _recognizers.putIfAbsent(
|
||||
userId,
|
||||
() => TapGestureRecognizer()..onTap = () => widget.onUserTap?.call(userId),
|
||||
);
|
||||
|
||||
String _nameOf(int userId) => ContactCache.get(userId) ?? 'Пользователь';
|
||||
|
||||
int? _mentionedUser(ControlAttachment control) {
|
||||
final direct = control.userId;
|
||||
if (direct != null && direct != 0) return direct;
|
||||
final ids = control.userIds;
|
||||
if (ids != null && ids.length == 1) return ids.first;
|
||||
return null;
|
||||
}
|
||||
|
||||
_ControlText _resolveText(ControlAttachment control) {
|
||||
final senderId = widget.message.senderId;
|
||||
final sender = _ControlSegment(_nameOf(senderId), senderId);
|
||||
|
||||
switch (control.event) {
|
||||
case 'new':
|
||||
return _ControlText([
|
||||
sender,
|
||||
const _ControlSegment(' создал(а) чат'),
|
||||
], senderId);
|
||||
case 'add':
|
||||
final ids = control.userIds ?? const <int>[];
|
||||
final segments = <_ControlSegment>[
|
||||
sender,
|
||||
const _ControlSegment(' добавил(а) '),
|
||||
];
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
if (i > 0) segments.add(const _ControlSegment(', '));
|
||||
segments.add(_ControlSegment(_nameOf(ids[i]), ids[i]));
|
||||
}
|
||||
return _ControlText(segments, ids.length == 1 ? ids.first : null);
|
||||
case 'leave':
|
||||
return _ControlText([
|
||||
sender,
|
||||
const _ControlSegment(' покинул(а) чат'),
|
||||
], senderId);
|
||||
case 'joinByLink':
|
||||
return _ControlText([
|
||||
sender,
|
||||
const _ControlSegment(' присоединился(-ась) к чату'),
|
||||
], senderId);
|
||||
case 'pin':
|
||||
return _ControlText([
|
||||
sender,
|
||||
const _ControlSegment(' закрепил(а) сообщение'),
|
||||
], senderId);
|
||||
default:
|
||||
return _ControlText([
|
||||
_ControlSegment(control.title ?? ''),
|
||||
], _mentionedUser(control) ?? senderId);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final attachments = widget.message.attachments;
|
||||
if (attachments == null || attachments.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final control = attachments.first;
|
||||
if (control is! ControlAttachment) return const SizedBox.shrink();
|
||||
|
||||
final resolved = _resolveText(control);
|
||||
if (resolved.segments.every((s) => s.text.isEmpty)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final cs = widget.cs;
|
||||
final interactive = widget.onUserTap != null;
|
||||
|
||||
final bubble = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
for (final segment in resolved.segments)
|
||||
TextSpan(
|
||||
text: segment.text,
|
||||
style: interactive && segment.userId != null
|
||||
? const TextStyle(fontWeight: FontWeight.w600)
|
||||
: null,
|
||||
recognizer: interactive && segment.userId != null
|
||||
? _recognizerFor(segment.userId!)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
|
||||
final tapUserId = resolved.tapUserId;
|
||||
if (!interactive || tapUserId == null) return bubble;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => widget.onUserTap!(tapUserId),
|
||||
child: bubble,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import '../../screens/chats/chat_screen.dart';
|
||||
import '../custom_notification.dart';
|
||||
import '../komet_avatar.dart';
|
||||
import '../photo_viewer.dart';
|
||||
import '../reload_on_reconnect.dart';
|
||||
import '../small_spinner.dart';
|
||||
import '../swipe_route.dart';
|
||||
import '../video_player_screen.dart';
|
||||
@@ -312,7 +313,8 @@ class CommonChatsTab extends StatefulWidget {
|
||||
State<CommonChatsTab> createState() => _CommonChatsTabState();
|
||||
}
|
||||
|
||||
class _CommonChatsTabState extends State<CommonChatsTab> {
|
||||
class _CommonChatsTabState extends State<CommonChatsTab>
|
||||
with ReloadOnReconnect {
|
||||
bool _loading = true;
|
||||
List<CommonChatEntry> _chats = const [];
|
||||
Map<int, int> _onlineByChat = const {};
|
||||
@@ -323,6 +325,9 @@ class _CommonChatsTabState extends State<CommonChatsTab> {
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void reloadAfterReconnect() => _load();
|
||||
|
||||
Future<void> _load() async {
|
||||
final chats = await sharedContentModule.fetchCommonChats(widget.userId);
|
||||
|
||||
@@ -474,7 +479,8 @@ class SharedMediaTab extends StatefulWidget {
|
||||
State<SharedMediaTab> createState() => _SharedMediaTabState();
|
||||
}
|
||||
|
||||
class _SharedMediaTabState extends State<SharedMediaTab> {
|
||||
class _SharedMediaTabState extends State<SharedMediaTab>
|
||||
with ReloadOnReconnect {
|
||||
static const int _pageSize = 60;
|
||||
|
||||
bool _loading = true;
|
||||
@@ -507,6 +513,9 @@ class _SharedMediaTabState extends State<SharedMediaTab> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void reloadAfterReconnect() => _load(widget.anchorMessageId, initial: true);
|
||||
|
||||
Future<void> _load(String anchor, {required bool initial}) async {
|
||||
final page = await sharedContentModule.fetchMedia(
|
||||
chatId: widget.chatId,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
@@ -26,6 +28,8 @@ void showChatMenu({
|
||||
required BuildContext context,
|
||||
required Rect anchorRect,
|
||||
required List<ChatMenuItem> items,
|
||||
Widget? header,
|
||||
Widget? footer,
|
||||
}) {
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
late OverlayEntry entry;
|
||||
@@ -33,6 +37,8 @@ void showChatMenu({
|
||||
builder: (ctx) => _ChatMenuLayer(
|
||||
anchorRect: anchorRect,
|
||||
items: items,
|
||||
header: header,
|
||||
footer: footer,
|
||||
onDismiss: () {
|
||||
if (entry.mounted) entry.remove();
|
||||
},
|
||||
@@ -45,25 +51,72 @@ void showChatMenu({
|
||||
class _ChatMenuLayer extends StatefulWidget {
|
||||
final Rect anchorRect;
|
||||
final List<ChatMenuItem> items;
|
||||
final Widget? header;
|
||||
final Widget? footer;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
const _ChatMenuLayer({
|
||||
required this.anchorRect,
|
||||
required this.items,
|
||||
required this.onDismiss,
|
||||
this.header,
|
||||
this.footer,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ChatMenuLayer> createState() => _ChatMenuLayerState();
|
||||
}
|
||||
|
||||
class _MenuLayout extends SingleChildLayoutDelegate {
|
||||
static const double menuWidth = 290.0;
|
||||
static const double margin = 8.0;
|
||||
static const double gap = 6.0;
|
||||
|
||||
final Rect anchor;
|
||||
final EdgeInsets safeArea;
|
||||
|
||||
const _MenuLayout({required this.anchor, required this.safeArea});
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
final width = math.min(menuWidth, constraints.maxWidth - margin * 2);
|
||||
final available =
|
||||
constraints.maxHeight - safeArea.top - safeArea.bottom - margin * 2;
|
||||
return BoxConstraints(
|
||||
minWidth: math.max(0, width),
|
||||
maxWidth: math.max(0, width),
|
||||
maxHeight: math.max(120.0, available),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
final maxLeft = math.max(margin, size.width - childSize.width - margin);
|
||||
final left = (anchor.right - childSize.width).clamp(margin, maxLeft);
|
||||
|
||||
final topLimit = safeArea.top + margin;
|
||||
final bottomLimit = size.height - safeArea.bottom - margin;
|
||||
final below = anchor.bottom + gap;
|
||||
final above = anchor.top - gap - childSize.height;
|
||||
|
||||
double top;
|
||||
if (below + childSize.height <= bottomLimit) {
|
||||
top = below;
|
||||
} else if (above >= topLimit) {
|
||||
top = above;
|
||||
} else {
|
||||
top = bottomLimit - childSize.height;
|
||||
}
|
||||
return Offset(left, math.max(topLimit, top));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_MenuLayout oldDelegate) =>
|
||||
oldDelegate.anchor != anchor || oldDelegate.safeArea != safeArea;
|
||||
}
|
||||
|
||||
class _ChatMenuLayerState extends State<_ChatMenuLayer>
|
||||
with SingleTickerProviderStateMixin, AnimatedOverlayPopup<_ChatMenuLayer> {
|
||||
static const double _menuWidth = 290.0;
|
||||
static const double _hMargin = 8.0;
|
||||
static const double _vMargin = 8.0;
|
||||
static const double _gap = 6.0;
|
||||
|
||||
@override
|
||||
Duration get overlayForwardDuration => const Duration(milliseconds: 220);
|
||||
|
||||
@@ -78,29 +131,10 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer>
|
||||
closeOverlay().then((_) => item.onTap?.call());
|
||||
}
|
||||
|
||||
Rect _resolveRect(Size screen) {
|
||||
final maxWidth = screen.width - 2 * _hMargin;
|
||||
final width = maxWidth <= 0
|
||||
? screen.width
|
||||
: (_menuWidth.clamp(0.0, maxWidth));
|
||||
final maxLeft = screen.width - width - _hMargin;
|
||||
double left = widget.anchorRect.right - width;
|
||||
if (left > maxLeft) left = maxLeft;
|
||||
if (left < _hMargin) left = _hMargin;
|
||||
final top = widget.anchorRect.bottom + _gap;
|
||||
return Rect.fromLTWH(left, top, width, 0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final screen = MediaQuery.sizeOf(context);
|
||||
final bottomInset = MediaQuery.paddingOf(context).bottom;
|
||||
final rect = _resolveRect(screen);
|
||||
final maxHeight = (screen.height - rect.top - bottomInset - _vMargin).clamp(
|
||||
120.0,
|
||||
double.infinity,
|
||||
);
|
||||
final safeArea = MediaQuery.paddingOf(context);
|
||||
return AnimatedBuilder(
|
||||
animation: overlayAnimation,
|
||||
builder: (ctx, child) {
|
||||
@@ -115,16 +149,19 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer>
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: Transform.scale(
|
||||
scale: scale,
|
||||
alignment: Alignment.topRight,
|
||||
child: child,
|
||||
Positioned.fill(
|
||||
child: CustomSingleChildLayout(
|
||||
delegate: _MenuLayout(
|
||||
anchor: widget.anchorRect,
|
||||
safeArea: safeArea,
|
||||
),
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: Transform.scale(
|
||||
scale: scale,
|
||||
alignment: Alignment.topRight,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -137,25 +174,39 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer>
|
||||
clipBehavior: Clip.antiAlias,
|
||||
elevation: 12,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.45),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 6),
|
||||
for (final item in widget.items) ...[
|
||||
_ChatMenuRow(item: item, onTap: () => _onItemTap(item)),
|
||||
if (item.dividerAfter)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: cs.onSurface.withValues(alpha: 0.07),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.header != null) ...[
|
||||
widget.header!,
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: cs.onSurface.withValues(alpha: 0.07),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
for (final item in widget.items) ...[
|
||||
_ChatMenuRow(item: item, onTap: () => _onItemTap(item)),
|
||||
if (item.dividerAfter)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: cs.onSurface.withValues(alpha: 0.07),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
if (widget.footer != null) ...[
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: cs.onSurface.withValues(alpha: 0.07),
|
||||
),
|
||||
widget.footer!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+26
-8
@@ -1,12 +1,18 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
|
||||
class RightwardDragRecognizer extends HorizontalDragGestureRecognizer {
|
||||
RightwardDragRecognizer({super.debugOwner}) {
|
||||
class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer {
|
||||
DirectionalDragRecognizer({
|
||||
required this.direction,
|
||||
this.minAcceptDistance = 20.0,
|
||||
this.minAcceptVelocity,
|
||||
super.debugOwner,
|
||||
}) {
|
||||
onlyAcceptDragOnThreshold = true;
|
||||
}
|
||||
|
||||
static const double _kMinAcceptVelocity = 700.0;
|
||||
static const double _kMinAcceptDistance = 20.0;
|
||||
final double direction;
|
||||
final double minAcceptDistance;
|
||||
final double? minAcceptVelocity;
|
||||
|
||||
final Map<int, Offset> _initialPositions = {};
|
||||
final Map<int, VelocityTracker> _velocityTrackers = {};
|
||||
@@ -30,7 +36,7 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer {
|
||||
);
|
||||
final initial = _initialPositions[event.pointer];
|
||||
if (initial != null) {
|
||||
final dx = event.position.dx - initial.dx;
|
||||
final dx = (event.position.dx - initial.dx) * direction;
|
||||
_currentDeltaX[event.pointer] = dx;
|
||||
if (dx < -kTouchSlop) {
|
||||
stopTrackingPointer(event.pointer);
|
||||
@@ -57,10 +63,13 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer {
|
||||
for (final dx in _currentDeltaX.values) {
|
||||
if (dx > maxDx) maxDx = dx;
|
||||
}
|
||||
if (maxDx < _kMinAcceptDistance) return false;
|
||||
if (maxDx < minAcceptDistance) return false;
|
||||
|
||||
final minVelocity = minAcceptVelocity;
|
||||
if (minVelocity == null) return true;
|
||||
for (final tracker in _velocityTrackers.values) {
|
||||
final vx = tracker.getVelocity().pixelsPerSecond.dx;
|
||||
if (vx >= _kMinAcceptVelocity) return true;
|
||||
final vx = tracker.getVelocity().pixelsPerSecond.dx * direction;
|
||||
if (vx >= minVelocity) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -83,3 +92,12 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer {
|
||||
super.rejectGesture(pointer);
|
||||
}
|
||||
}
|
||||
|
||||
class RightwardDragRecognizer extends DirectionalDragRecognizer {
|
||||
RightwardDragRecognizer({super.debugOwner})
|
||||
: super(direction: 1, minAcceptVelocity: 700);
|
||||
}
|
||||
|
||||
class LeftwardDragRecognizer extends DirectionalDragRecognizer {
|
||||
LeftwardDragRecognizer({super.debugOwner}) : super(direction: -1);
|
||||
}
|
||||
@@ -1,16 +1,29 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../backend/modules/messages.dart' show ContactCache;
|
||||
import '../../core/utils/link_opener.dart';
|
||||
import '../../core/utils/text_entities.dart';
|
||||
import '../../core/utils/text_format.dart';
|
||||
import '../screens/contacts/open_contact_profile.dart';
|
||||
import 'link_text.dart';
|
||||
import 'lottie_image.dart';
|
||||
import 'text_entity_actions.dart';
|
||||
|
||||
Color mentionTextColor(ColorScheme cs) => cs.primary;
|
||||
|
||||
enum TextEntityMode { menu, copy }
|
||||
|
||||
class FormattedMessageText extends StatefulWidget {
|
||||
final String text;
|
||||
final List<FormatRange> ranges;
|
||||
final TextStyle style;
|
||||
final TextAlign textAlign;
|
||||
final TextEntityMode entityMode;
|
||||
final int? maxLines;
|
||||
final TextOverflow? overflow;
|
||||
|
||||
const FormattedMessageText({
|
||||
super.key,
|
||||
@@ -18,18 +31,22 @@ class FormattedMessageText extends StatefulWidget {
|
||||
required this.ranges,
|
||||
required this.style,
|
||||
this.textAlign = TextAlign.start,
|
||||
this.entityMode = TextEntityMode.menu,
|
||||
this.maxLines,
|
||||
this.overflow,
|
||||
});
|
||||
|
||||
static bool isFormatted(String? text, List<FormatRange> ranges) =>
|
||||
text != null &&
|
||||
text.isNotEmpty &&
|
||||
(ranges.isNotEmpty || LinkText.hasLinks(text));
|
||||
(ranges.isNotEmpty || LinkText.hasLinks(text) || hasTextEntities(text));
|
||||
|
||||
static TextSpan buildInlineSpan(
|
||||
String text,
|
||||
List<FormatRange> ranges,
|
||||
TextStyle style,
|
||||
) {
|
||||
TextStyle style, {
|
||||
Color? mentionColor,
|
||||
}) {
|
||||
final quoteColor = style.color?.withValues(alpha: 0.85);
|
||||
final segments = segmentizeFormats(text, ranges);
|
||||
return TextSpan(
|
||||
@@ -42,6 +59,7 @@ class FormattedMessageText extends StatefulWidget {
|
||||
style,
|
||||
segment.formats,
|
||||
quoteColor: quoteColor,
|
||||
mentionColor: mentionColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -53,7 +71,7 @@ class FormattedMessageText extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _FormattedMessageTextState extends State<FormattedMessageText> {
|
||||
final List<TapGestureRecognizer> _recognizers = [];
|
||||
final List<GestureRecognizer> _recognizers = [];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -87,13 +105,113 @@ class _FormattedMessageTextState extends State<FormattedMessageText> {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
void _openMention(int userId) {
|
||||
unawaited(
|
||||
openContactDialogProfile(
|
||||
context,
|
||||
contactId: userId,
|
||||
name: ContactCache.get(userId) ?? 'User #$userId',
|
||||
avatarUrl: ContactCache.getAvatar(userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
T _track<T extends GestureRecognizer>(T recognizer) {
|
||||
_recognizers.add(recognizer);
|
||||
return recognizer;
|
||||
}
|
||||
|
||||
GestureRecognizer? _entityRecognizer(TextEntity entity) {
|
||||
switch (entity.kind) {
|
||||
case TextEntityKind.mention:
|
||||
return _track(
|
||||
TapGestureRecognizer()
|
||||
..onTap = () =>
|
||||
unawaited(openMentionProfile(context, entity.value)),
|
||||
);
|
||||
case TextEntityKind.phone:
|
||||
if (widget.entityMode == TextEntityMode.copy) {
|
||||
return _track(
|
||||
TapGestureRecognizer()
|
||||
..onTap = () => unawaited(
|
||||
copyTextEntity(context, entity.value, 'Номер скопирован'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _track(
|
||||
LongPressGestureRecognizer()
|
||||
..onLongPressStart = (details) => showPhoneEntityMenu(
|
||||
context,
|
||||
entity.value,
|
||||
at: details.globalPosition,
|
||||
),
|
||||
);
|
||||
case TextEntityKind.card:
|
||||
if (widget.entityMode == TextEntityMode.copy) {
|
||||
return _track(
|
||||
TapGestureRecognizer()
|
||||
..onTap = () => unawaited(
|
||||
copyTextEntity(context, entity.value, 'Номер карты скопирован'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _track(
|
||||
LongPressGestureRecognizer()
|
||||
..onLongPressStart = (details) => showCardEntityMenu(
|
||||
context,
|
||||
entity.value,
|
||||
at: details.globalPosition,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<TextSpanRange> _claimedRanges(List<FormatRange> ranges) => [
|
||||
for (final range in ranges)
|
||||
if (range.format == TextFormat.link ||
|
||||
range.format == TextFormat.userMention)
|
||||
(start: range.start, end: range.end),
|
||||
];
|
||||
|
||||
TextEntity? _entityAt(List<TextEntity> entities, int start, int end) {
|
||||
for (final entity in entities) {
|
||||
if (entity.start <= start && entity.end >= end) return entity;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<({int start, int end})> _splitByEntities(
|
||||
int start,
|
||||
int end,
|
||||
List<TextEntity> entities,
|
||||
) {
|
||||
final points = <int>{start, end};
|
||||
for (final entity in entities) {
|
||||
if (entity.end <= start || entity.start >= end) continue;
|
||||
if (entity.start > start) points.add(entity.start);
|
||||
if (entity.end < end) points.add(entity.end);
|
||||
}
|
||||
final sorted = points.toList()..sort();
|
||||
return [
|
||||
for (var i = 0; i < sorted.length - 1; i++)
|
||||
(start: sorted[i], end: sorted[i + 1]),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_disposeRecognizers();
|
||||
final segments = segmentizeFormats(widget.text, _withAutoLinks());
|
||||
final baseColor = widget.style.color ?? Theme.of(context).colorScheme.onSurface;
|
||||
final ranges = _withAutoLinks();
|
||||
final entities = detectTextEntities(
|
||||
widget.text,
|
||||
skip: _claimedRanges(ranges),
|
||||
);
|
||||
final segments = segmentizeFormats(widget.text, ranges);
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final baseColor = widget.style.color ?? cs.onSurface;
|
||||
final barColor = baseColor.withValues(alpha: 0.4);
|
||||
final quoteColor = baseColor.withValues(alpha: 0.85);
|
||||
final mentionColor = mentionTextColor(cs);
|
||||
|
||||
final spans = <InlineSpan>[];
|
||||
var prevQuote = false;
|
||||
@@ -121,6 +239,7 @@ class _FormattedMessageTextState extends State<FormattedMessageText> {
|
||||
widget.style,
|
||||
segment.formats,
|
||||
quoteColor: quoteColor,
|
||||
mentionColor: mentionColor,
|
||||
);
|
||||
final content = widget.text.substring(segment.start, segment.end);
|
||||
if (segment.animojiUrl != null) {
|
||||
@@ -153,22 +272,72 @@ class _FormattedMessageTextState extends State<FormattedMessageText> {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
final mentionId = segment.mentionId;
|
||||
if (mentionId != null && mentionId != 0) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: content,
|
||||
style: style,
|
||||
recognizer: _track(
|
||||
TapGestureRecognizer()..onTap = () => _openMention(mentionId),
|
||||
),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
final mentionName = segment.mentionName;
|
||||
if (mentionName != null) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: content,
|
||||
style: style,
|
||||
recognizer: _track(
|
||||
TapGestureRecognizer()
|
||||
..onTap = () =>
|
||||
unawaited(openMentionProfile(context, mentionName)),
|
||||
),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segment.url != null) {
|
||||
final url = segment.url!;
|
||||
final recognizer = TapGestureRecognizer()
|
||||
..onTap = () => openExternalUrl(context, url);
|
||||
_recognizers.add(recognizer);
|
||||
spans.add(
|
||||
TextSpan(text: content, style: style, recognizer: recognizer),
|
||||
TextSpan(
|
||||
text: content,
|
||||
style: style,
|
||||
recognizer: _track(
|
||||
TapGestureRecognizer()
|
||||
..onTap = () => openExternalUrl(context, url),
|
||||
),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final piece in _splitByEntities(
|
||||
segment.start,
|
||||
segment.end,
|
||||
entities,
|
||||
)) {
|
||||
final entity = _entityAt(entities, piece.start, piece.end);
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: widget.text.substring(piece.start, piece.end),
|
||||
style: entity == null ? style : style.copyWith(color: mentionColor),
|
||||
recognizer: entity == null ? null : _entityRecognizer(entity),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
spans.add(TextSpan(text: content, style: style));
|
||||
}
|
||||
}
|
||||
|
||||
return Text.rich(
|
||||
TextSpan(style: widget.style, children: spans),
|
||||
textAlign: widget.textAlign,
|
||||
maxLines: widget.maxLines,
|
||||
overflow: widget.overflow ?? TextOverflow.clip,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../screens/chats/chat/mention_panel_controller.dart';
|
||||
import 'komet_avatar.dart';
|
||||
import 'small_spinner.dart';
|
||||
|
||||
class MentionSuggestionsPanel extends StatefulWidget {
|
||||
final List<MentionCandidate> candidates;
|
||||
final double maxHeight;
|
||||
final bool loadingMore;
|
||||
final ValueChanged<MentionCandidate> onSelected;
|
||||
final VoidCallback onLoadMore;
|
||||
|
||||
const MentionSuggestionsPanel({
|
||||
super.key,
|
||||
required this.candidates,
|
||||
required this.onSelected,
|
||||
required this.onLoadMore,
|
||||
this.loadingMore = false,
|
||||
this.maxHeight = 220,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MentionSuggestionsPanel> createState() =>
|
||||
_MentionSuggestionsPanelState();
|
||||
}
|
||||
|
||||
class _MentionSuggestionsPanelState extends State<MentionSuggestionsPanel> {
|
||||
final ScrollController _controller = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_onScroll);
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!_controller.hasClients) return;
|
||||
final position = _controller.position;
|
||||
if (position.pixels >= position.maxScrollExtent - 120) {
|
||||
widget.onLoadMore();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final candidates = widget.candidates;
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxHeight: widget.maxHeight),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: candidates.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
child: Center(
|
||||
child: SmallSpinner(size: 20, color: cs.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
controller: _controller,
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
itemCount: candidates.length + (widget.loadingMore ? 1 : 0),
|
||||
separatorBuilder: (_, _) => Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 14,
|
||||
endIndent: 14,
|
||||
color: cs.outlineVariant.withValues(alpha: 0.18),
|
||||
),
|
||||
itemBuilder: (context, i) {
|
||||
if (i >= candidates.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Center(
|
||||
child: SmallSpinner(
|
||||
size: 18,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final candidate = candidates[i];
|
||||
return InkWell(
|
||||
onTap: () => widget.onSelected(candidate),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
KometAvatar(
|
||||
name: candidate.name,
|
||||
imageUrl: candidate.avatarUrl,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
candidate.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import 'attachment/bubbles/bubble_context.dart';
|
||||
import 'attachment/bubbles/poll_bubble.dart';
|
||||
import 'attachment/bubbles/share_bubble.dart';
|
||||
import 'attachment/bubbles/call_bubble.dart';
|
||||
import 'attachment/bubbles/control_bubble.dart';
|
||||
import 'attachment/bubbles/location_bubble.dart';
|
||||
import 'attachment/bubbles/contact_bubble.dart';
|
||||
import 'attachment/bubbles/sticker_bubble.dart';
|
||||
@@ -282,6 +283,14 @@ class MessageBubble extends StatelessWidget {
|
||||
return photoCount >= 2 && !hasCaption;
|
||||
}
|
||||
|
||||
bool get _showsSenderName =>
|
||||
!isMe &&
|
||||
chatType == "CHAT" &&
|
||||
prevMessage?.senderId != message.senderId;
|
||||
|
||||
bool get _stretchesTextRow =>
|
||||
message.replyInfo != null || _showsSenderName;
|
||||
|
||||
BubbleShape _computeShape() {
|
||||
if (message.isControl) return BubbleShape.singleMiddle;
|
||||
|
||||
@@ -589,10 +598,7 @@ class MessageBubble extends StatelessWidget {
|
||||
showAvatarSlot &&
|
||||
chatType == "CHAT" &&
|
||||
nextMessage?.senderId != message.senderId;
|
||||
final showSenderName =
|
||||
showAvatarSlot &&
|
||||
chatType == "CHAT" &&
|
||||
prevMessage?.senderId != message.senderId;
|
||||
final showSenderName = _showsSenderName;
|
||||
|
||||
final maxBubbleWidth = math.min(MediaQuery.sizeOf(context).width * 0.75, 560.0);
|
||||
final keyboard = _inlineKeyboard;
|
||||
@@ -635,51 +641,60 @@ class MessageBubble extends StatelessWidget {
|
||||
final reactionsInside = contentType != MessageType.text && !reactionsUnder;
|
||||
|
||||
final reply = message.replyInfo;
|
||||
Widget withReply(Widget content) {
|
||||
if (reply == null) return content;
|
||||
final quote = _buildReplyQuote(context, cs, textColor, reply);
|
||||
if (contentType != MessageType.text || jumboAnimoji != null) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [quote, const SizedBox(height: 4), content],
|
||||
);
|
||||
}
|
||||
return IntrinsicWidth(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_ZeroIntrinsicWidth(child: quote),
|
||||
const SizedBox(height: 4),
|
||||
content,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final bool hasCommentsFooter = onCommentsTap != null;
|
||||
final EdgeInsets containerPadding = hasCommentsFooter
|
||||
? EdgeInsets.zero
|
||||
: padding;
|
||||
|
||||
final Widget innerContent = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showSenderName)
|
||||
_buildSenderHeader(cs, padding == EdgeInsets.zero),
|
||||
withReply(
|
||||
reactionsInside
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [bubbleContent, _reactionsBar(cs)],
|
||||
)
|
||||
: bubbleContent,
|
||||
),
|
||||
],
|
||||
);
|
||||
final Widget contentWithReactions = reactionsInside
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [bubbleContent, _reactionsBar(cs)],
|
||||
)
|
||||
: bubbleContent;
|
||||
|
||||
final Widget? senderHeader = showSenderName
|
||||
? _buildSenderHeader(cs, padding == EdgeInsets.zero)
|
||||
: null;
|
||||
|
||||
final Widget innerContent =
|
||||
contentType == MessageType.text &&
|
||||
jumboAnimoji == null &&
|
||||
_stretchesTextRow
|
||||
? IntrinsicWidth(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (senderHeader != null)
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: senderHeader,
|
||||
),
|
||||
if (reply != null) ...[
|
||||
_ZeroIntrinsicWidth(
|
||||
child: _buildReplyQuote(context, cs, textColor, reply),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
contentWithReactions,
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
?senderHeader,
|
||||
if (reply != null) ...[
|
||||
_buildReplyQuote(context, cs, textColor, reply),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
contentWithReactions,
|
||||
],
|
||||
);
|
||||
|
||||
final Widget bubbleBox = ListenableBuilder(
|
||||
listenable: Listenable.merge([
|
||||
@@ -1199,66 +1214,12 @@ class MessageBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControlContent(ColorScheme cs) {
|
||||
final attachments = message.attachments;
|
||||
if (attachments == null || attachments.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final control = attachments.first;
|
||||
if (control is! ControlAttachment) return const SizedBox.shrink();
|
||||
|
||||
String? text;
|
||||
switch (control.event) {
|
||||
case 'system':
|
||||
text = control.title;
|
||||
break;
|
||||
case 'new':
|
||||
text =
|
||||
'${ContactCache.get(message.senderId) ?? 'Пользователь'} создал(а) чат';
|
||||
break;
|
||||
case 'add':
|
||||
final names = (control.userIds ?? [])
|
||||
.map((id) => ContactCache.get(id) ?? 'Пользователь')
|
||||
.join(', ');
|
||||
text =
|
||||
'${ContactCache.get(message.senderId) ?? 'Пользователь'} добавил(а) $names';
|
||||
break;
|
||||
case 'leave':
|
||||
text =
|
||||
'${ContactCache.get(message.senderId) ?? 'Пользователь'} покинул(а) чат';
|
||||
break;
|
||||
case 'joinByLink':
|
||||
text =
|
||||
'${ContactCache.get(message.senderId) ?? 'Пользователь'} присоединился(-ась) к чату';
|
||||
break;
|
||||
case 'pin':
|
||||
text =
|
||||
'${ContactCache.get(message.senderId) ?? 'Пользователь'} закрепил(а) сообщение';
|
||||
break;
|
||||
default:
|
||||
text = control.title;
|
||||
}
|
||||
|
||||
if (text == null || text.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget _buildControlContent(ColorScheme cs) => ControlBubble(
|
||||
key: ValueKey('control_${message.id}'),
|
||||
message: message,
|
||||
cs: cs,
|
||||
onUserTap: onAvatarTap,
|
||||
);
|
||||
|
||||
Widget _wrapSelectable(Widget textWidget) {
|
||||
final listenable = textSelection;
|
||||
@@ -1366,7 +1327,9 @@ class MessageBubble extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(child: textWidget),
|
||||
_stretchesTextRow
|
||||
? Expanded(child: textWidget)
|
||||
: Flexible(child: textWidget),
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
|
||||
@@ -121,7 +121,10 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_items = _localItems();
|
||||
_index = widget.initialIndex.clamp(0, _items.length - 1);
|
||||
_index = (_items.length - 1 - widget.initialIndex).clamp(
|
||||
0,
|
||||
_items.length - 1,
|
||||
);
|
||||
_controller = PageController(initialPage: _index);
|
||||
unawaited(_loadFeed());
|
||||
}
|
||||
@@ -135,7 +138,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
List<_ViewerPhoto> _localItems() {
|
||||
final message = widget.message;
|
||||
return [
|
||||
for (var i = 0; i < widget.photos.length; i++)
|
||||
for (var i = widget.photos.length - 1; i >= 0; i--)
|
||||
_ViewerPhoto(
|
||||
id: _localId(widget.photos[i], message, i),
|
||||
photo: widget.photos[i],
|
||||
@@ -147,6 +150,23 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
];
|
||||
}
|
||||
|
||||
List<_ViewerPhoto> _feedItems(List<SharedMediaItem> items) {
|
||||
final out = <_ViewerPhoto>[];
|
||||
var start = 0;
|
||||
while (start < items.length) {
|
||||
var end = start;
|
||||
while (end + 1 < items.length &&
|
||||
items[end + 1].messageId == items[start].messageId) {
|
||||
end++;
|
||||
}
|
||||
for (var i = end; i >= start; i--) {
|
||||
out.add(_ViewerPhoto.fromFeed(items[i]));
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
String _localId(PhotoAttachment photo, CachedMessage? message, int at) {
|
||||
final key = _feedKey(photo, message);
|
||||
return key ?? 'local:${message?.id ?? ''}:$at';
|
||||
@@ -182,7 +202,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final items = feed.items.map(_ViewerPhoto.fromFeed).toList();
|
||||
final items = _feedItems(feed.items);
|
||||
final at = items.indexWhere((i) => i.id == key);
|
||||
if (at == -1) {
|
||||
setState(() => _feedFailed = true);
|
||||
@@ -224,7 +244,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final items = feed.items.map(_ViewerPhoto.fromFeed).toList();
|
||||
final items = _feedItems(feed.items);
|
||||
final at = items.indexWhere((i) => i.id == _current.id);
|
||||
if (at == -1) {
|
||||
setState(() {
|
||||
@@ -413,8 +433,8 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
backgroundColor: Colors.black,
|
||||
body: CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _step(-1),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight): () => _step(1),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _step(1),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight): () => _step(-1),
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
@@ -424,6 +444,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
child: PageView.builder(
|
||||
key: ValueKey(_pager),
|
||||
controller: _controller,
|
||||
reverse: true,
|
||||
itemCount: _items.length,
|
||||
onPageChanged: _onPageChanged,
|
||||
itemBuilder: (_, i) => GestureDetector(
|
||||
@@ -451,15 +472,18 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
curve: Curves.easeOut,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (_index > 0)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: _arrow(Symbols.chevron_left, () => _step(-1)),
|
||||
),
|
||||
if (_index < _items.length - 1)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: _arrow(Symbols.chevron_left, () => _step(1)),
|
||||
),
|
||||
if (_index > 0)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: _arrow(Symbols.chevron_right, () => _step(1)),
|
||||
child: _arrow(
|
||||
Symbols.chevron_right,
|
||||
() => _step(-1),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: padding.top + 8,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../backend/api.dart';
|
||||
import '../../main.dart' show api;
|
||||
|
||||
mixin ReloadOnReconnect<T extends StatefulWidget> on State<T> {
|
||||
StreamSubscription<SessionState>? _reconnectSub;
|
||||
int _reloadedEpoch = api.sessionEpoch;
|
||||
|
||||
void reloadAfterReconnect();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_reconnectSub = api.stateStream.listen(_onSessionState);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_reconnectSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSessionState(SessionState state) {
|
||||
if (state != SessionState.online) return;
|
||||
if (api.sessionEpoch == _reloadedEpoch) return;
|
||||
_reloadedEpoch = api.sessionEpoch;
|
||||
if (mounted) reloadAfterReconnect();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/utils/text_format.dart';
|
||||
import '../../models/animoji.dart';
|
||||
import 'formatted_message_text.dart';
|
||||
import 'lottie_image.dart';
|
||||
|
||||
const List<TextFormat> composerFormats = [
|
||||
@@ -18,6 +19,13 @@ class _Interval {
|
||||
_Interval(this.start, this.end);
|
||||
}
|
||||
|
||||
class _MentionEntity {
|
||||
int start;
|
||||
int end;
|
||||
final int userId;
|
||||
_MentionEntity(this.start, this.end, this.userId);
|
||||
}
|
||||
|
||||
class _AnimojiEntity {
|
||||
final int uid;
|
||||
int offset;
|
||||
@@ -39,6 +47,7 @@ class RichMessageController extends TextEditingController {
|
||||
|
||||
final Map<TextFormat, List<_Interval>> _intervals = {};
|
||||
final List<_AnimojiEntity> _animoji = [];
|
||||
final List<_MentionEntity> _mentions = [];
|
||||
int _entitySeq = 0;
|
||||
|
||||
RichMessageController({super.text});
|
||||
@@ -73,6 +82,27 @@ class RichMessageController extends TextEditingController {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void insertMention({
|
||||
required int userId,
|
||||
required String name,
|
||||
required int start,
|
||||
required int end,
|
||||
}) {
|
||||
final oldText = value.text;
|
||||
if (start < 0 || end > oldText.length || start > end || name.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final inserted = '$name ';
|
||||
value = TextEditingValue(
|
||||
text: oldText.replaceRange(start, end, inserted),
|
||||
selection: TextSelection.collapsed(offset: start + inserted.length),
|
||||
);
|
||||
|
||||
_mentions.add(_MentionEntity(start, start + name.length, userId));
|
||||
_mentions.sort((a, b) => a.start.compareTo(b.start));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
({String text, List<Map<String, dynamic>> elements}) buildContent() {
|
||||
final src = value.text;
|
||||
if (_animoji.isEmpty) {
|
||||
@@ -121,6 +151,7 @@ class RichMessageController extends TextEditingController {
|
||||
'type': textFormatToServer(range.format),
|
||||
'from': from,
|
||||
'length': to - from,
|
||||
if (range.entityId != null) 'entityId': range.entityId,
|
||||
});
|
||||
}
|
||||
return (text: glyphText, elements: elements);
|
||||
@@ -136,7 +167,8 @@ class RichMessageController extends TextEditingController {
|
||||
super.value = newValue;
|
||||
}
|
||||
|
||||
bool get hasFormatting => _intervals.values.any((list) => list.isNotEmpty);
|
||||
bool get hasFormatting =>
|
||||
_intervals.values.any((list) => list.isNotEmpty) || _mentions.isNotEmpty;
|
||||
|
||||
void clearFormatting() {
|
||||
if (_intervals.isEmpty) return;
|
||||
@@ -146,12 +178,21 @@ class RichMessageController extends TextEditingController {
|
||||
|
||||
void setFormatRanges(Iterable<FormatRange> ranges) {
|
||||
_intervals.clear();
|
||||
_mentions.clear();
|
||||
for (final range in ranges) {
|
||||
if (range.format == TextFormat.userMention) {
|
||||
final userId = range.entityId;
|
||||
if (userId != null) {
|
||||
_mentions.add(_MentionEntity(range.start, range.end, userId));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!composerFormats.contains(range.format)) continue;
|
||||
_intervals
|
||||
.putIfAbsent(range.format, () => [])
|
||||
.add(_Interval(range.start, range.end));
|
||||
}
|
||||
_mentions.sort((a, b) => a.start.compareTo(b.start));
|
||||
for (final list in _intervals.values) {
|
||||
_normalize(list);
|
||||
}
|
||||
@@ -175,6 +216,16 @@ class RichMessageController extends TextEditingController {
|
||||
);
|
||||
}
|
||||
});
|
||||
for (final mention in _mentions) {
|
||||
ranges.add(
|
||||
FormatRange(
|
||||
format: TextFormat.userMention,
|
||||
start: mention.start,
|
||||
length: mention.end - mention.start,
|
||||
entityId: mention.userId,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
@@ -200,7 +251,7 @@ class RichMessageController extends TextEditingController {
|
||||
}
|
||||
|
||||
void _remap(String oldText, String newText) {
|
||||
if (_intervals.isEmpty && _animoji.isEmpty) return;
|
||||
if (_intervals.isEmpty && _animoji.isEmpty && _mentions.isEmpty) return;
|
||||
final oldLen = oldText.length;
|
||||
final newLen = newText.length;
|
||||
|
||||
@@ -240,6 +291,16 @@ class RichMessageController extends TextEditingController {
|
||||
}
|
||||
}
|
||||
|
||||
if (_mentions.isNotEmpty) {
|
||||
_mentions.removeWhere(
|
||||
(mention) => changeStart < mention.end && oldChangeEnd > mention.start,
|
||||
);
|
||||
for (final mention in _mentions) {
|
||||
mention.start = mapStart(mention.start);
|
||||
mention.end = mapEnd(mention.end);
|
||||
}
|
||||
}
|
||||
|
||||
final empty = <TextFormat>[];
|
||||
_intervals.forEach((format, list) {
|
||||
for (final interval in list) {
|
||||
@@ -325,6 +386,7 @@ class RichMessageController extends TextEditingController {
|
||||
final ranges = _toFormatRanges();
|
||||
final baseColor = baseStyle.color;
|
||||
final quoteColor = baseColor?.withValues(alpha: 0.85);
|
||||
final mentionColor = mentionTextColor(Theme.of(context).colorScheme);
|
||||
final segments = segmentizeFormats(content, ranges);
|
||||
final entityByOffset = {for (final e in _animoji) e.offset: e};
|
||||
final box = (baseStyle.fontSize ?? 16) * 1.4;
|
||||
@@ -335,6 +397,7 @@ class RichMessageController extends TextEditingController {
|
||||
baseStyle,
|
||||
segment.formats,
|
||||
quoteColor: quoteColor,
|
||||
mentionColor: mentionColor,
|
||||
);
|
||||
var runStart = segment.start;
|
||||
var i = segment.start;
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'dart:ui';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import 'rightward_drag_recognizer.dart';
|
||||
import 'directional_drag_recognizer.dart';
|
||||
|
||||
class SwipeRoute<T> extends PageRoute<T> {
|
||||
SwipeRoute({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'rightward_drag_recognizer.dart';
|
||||
import 'directional_drag_recognizer.dart';
|
||||
|
||||
class SwipeToPop extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../backend/modules/contacts.dart';
|
||||
import '../../core/utils/text_entities.dart';
|
||||
import '../../main.dart' show api;
|
||||
import 'chat_menu_overlay.dart';
|
||||
import 'custom_notification.dart';
|
||||
import 'komet_avatar.dart';
|
||||
import 'max_link_handler.dart';
|
||||
import 'small_spinner.dart';
|
||||
|
||||
Future<void> openMentionProfile(BuildContext context, String nickname) async {
|
||||
final handled = await tryHandleMaxLink(context, 'https://max.ru/$nickname');
|
||||
if (handled || !context.mounted) return;
|
||||
showCustomNotification(context, 'Профиль @$nickname не найден');
|
||||
}
|
||||
|
||||
Future<void> copyTextEntity(
|
||||
BuildContext context,
|
||||
String value,
|
||||
String message,
|
||||
) async {
|
||||
await Clipboard.setData(ClipboardData(text: value));
|
||||
if (!context.mounted) return;
|
||||
showCustomNotification(context, message);
|
||||
}
|
||||
|
||||
void showPhoneEntityMenu(
|
||||
BuildContext context,
|
||||
String phone, {
|
||||
required Offset at,
|
||||
}) {
|
||||
showChatMenu(
|
||||
context: context,
|
||||
anchorRect: Rect.fromLTWH(at.dx, at.dy, 0, 0),
|
||||
header: _PhoneOwnerHeader(phone: phone),
|
||||
items: [
|
||||
ChatMenuItem(
|
||||
icon: Symbols.content_copy,
|
||||
label: 'Скопировать номер телефона',
|
||||
onTap: () => copyTextEntity(context, phone, 'Номер скопирован'),
|
||||
),
|
||||
if (defaultTargetPlatform == TargetPlatform.android)
|
||||
ChatMenuItem(
|
||||
icon: Symbols.call,
|
||||
label: 'Позвонить',
|
||||
onTap: () => _dial(context, phone),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void showCardEntityMenu(
|
||||
BuildContext context,
|
||||
String digits, {
|
||||
required Offset at,
|
||||
}) {
|
||||
showChatMenu(
|
||||
context: context,
|
||||
anchorRect: Rect.fromLTWH(at.dx, at.dy, 0, 0),
|
||||
items: [
|
||||
ChatMenuItem(
|
||||
icon: Symbols.content_copy,
|
||||
label: 'Скопировать номер карты',
|
||||
onTap: () => copyTextEntity(context, digits, 'Номер карты скопирован'),
|
||||
),
|
||||
],
|
||||
footer: _CardFooter(digits: digits),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _dial(BuildContext context, String phone) async {
|
||||
final uri = Uri(scheme: 'tel', path: phone);
|
||||
var launched = false;
|
||||
try {
|
||||
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (_) {
|
||||
launched = false;
|
||||
}
|
||||
if (launched || !context.mounted) return;
|
||||
showCustomNotification(context, 'Не удалось открыть приложение звонков');
|
||||
}
|
||||
|
||||
class _CardFooter extends StatelessWidget {
|
||||
final String digits;
|
||||
|
||||
const _CardFooter({required this.digits});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final title = cardBrandTitle(digits);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 12, 18, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
cardMask(digits),
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
||||
),
|
||||
if (title != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PhoneOwnerHeader extends StatefulWidget {
|
||||
final String phone;
|
||||
|
||||
const _PhoneOwnerHeader({required this.phone});
|
||||
|
||||
@override
|
||||
State<_PhoneOwnerHeader> createState() => _PhoneOwnerHeaderState();
|
||||
}
|
||||
|
||||
class _PhoneOwnerHeaderState extends State<_PhoneOwnerHeader> {
|
||||
late final Future<PhoneLookupResult?> _lookup = ContactsModule.findByPhone(
|
||||
api,
|
||||
widget.phone,
|
||||
silent: true,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return FutureBuilder<PhoneLookupResult?>(
|
||||
future: _lookup,
|
||||
builder: (context, snapshot) {
|
||||
final Widget content;
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
content = Row(
|
||||
children: [
|
||||
SmallSpinner(size: 18, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
widget.phone,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
final found = snapshot.data;
|
||||
content = found == null
|
||||
? Text(
|
||||
'Человека ещё нет в MAX',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
)
|
||||
: _OwnerRow(found: found, phone: widget.phone);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 12),
|
||||
child: content,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OwnerRow extends StatelessWidget {
|
||||
final PhoneLookupResult found;
|
||||
final String phone;
|
||||
|
||||
const _OwnerRow({required this.found, required this.phone});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final resolved = found.name;
|
||||
final name = (resolved == null || resolved.isEmpty) ? phone : resolved;
|
||||
return Row(
|
||||
children: [
|
||||
KometAvatar(name: name, size: 36, imageUrl: found.avatarUrl),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
phone,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,17 @@ class ContactName {
|
||||
String? get label {
|
||||
final n = name;
|
||||
if (n != null && n.trim().isNotEmpty) return n.trim();
|
||||
return fullName;
|
||||
}
|
||||
|
||||
String? get fullName {
|
||||
final combined = [firstName, lastName]
|
||||
.where((s) => s != null && s.trim().isNotEmpty)
|
||||
.map((s) => s!.trim())
|
||||
.join(' ');
|
||||
return combined.isEmpty ? null : combined;
|
||||
if (combined.isNotEmpty) return combined;
|
||||
final n = name;
|
||||
return (n != null && n.trim().isNotEmpty) ? n.trim() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +58,23 @@ class ContactInfo {
|
||||
return firstLabel;
|
||||
}
|
||||
|
||||
String? get customFullName => _fullNameOfType('CUSTOM');
|
||||
|
||||
String? get onemeFullName => _fullNameOfType('ONEME');
|
||||
|
||||
String? get fullName => customFullName ?? onemeFullName ?? displayName;
|
||||
|
||||
bool get isSavedContact => customFullName != null;
|
||||
|
||||
String? _fullNameOfType(String type) {
|
||||
for (final n in names) {
|
||||
if (n.type != type) continue;
|
||||
final full = n.fullName;
|
||||
if (full != null) return full;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? get firstName {
|
||||
for (final n in names) {
|
||||
final f = n.firstName;
|
||||
|
||||
Reference in New Issue
Block a user