feat/fix: Все кнопки в экране профиля(кроме жалоб). Фикс ожирения бабла. Новые сообщения теперь не тянут вниз если ты отлистал
This commit is contained in:
@@ -333,6 +333,7 @@ class AccountModule {
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ComplaintsModule.clear();
|
||||
ContactsModule.clearBlockedCache();
|
||||
banners.clear();
|
||||
chats.resetForAccountSwitch();
|
||||
|
||||
@@ -348,6 +349,7 @@ class AccountModule {
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ComplaintsModule.clear();
|
||||
ContactsModule.clearBlockedCache();
|
||||
banners.clear();
|
||||
chats.resetForAccountSwitch();
|
||||
|
||||
@@ -380,6 +382,7 @@ class AccountModule {
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ComplaintsModule.clear();
|
||||
ContactsModule.clearBlockedCache();
|
||||
banners.clear();
|
||||
chats.resetForAccountSwitch();
|
||||
await ContactsModule.primeCacheFromDb(accountId);
|
||||
@@ -431,6 +434,7 @@ class AccountModule {
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ComplaintsModule.clear();
|
||||
ContactsModule.clearBlockedCache();
|
||||
banners.clear();
|
||||
chats.resetForAccountSwitch();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
|
||||
class ComplaintReason {
|
||||
final int reasonId;
|
||||
@@ -9,6 +10,8 @@ class ComplaintReason {
|
||||
}
|
||||
|
||||
class ComplaintsModule {
|
||||
static const int userTypeId = 6;
|
||||
|
||||
static Map<int, List<ComplaintReason>>? _cache;
|
||||
|
||||
static void clear() => _cache = null;
|
||||
@@ -17,10 +20,15 @@ class ComplaintsModule {
|
||||
final cached = _cache;
|
||||
if (cached != null) return cached;
|
||||
|
||||
final response = await api.sendRequest(Opcode.complainReasonsGet, {
|
||||
'complainSync': 0,
|
||||
});
|
||||
if (!response.isOk) return cached ?? const {};
|
||||
final Packet response;
|
||||
try {
|
||||
response = await api.sendRequest(Opcode.complainReasonsGet, {
|
||||
'complainSync': 0,
|
||||
}, silent: true);
|
||||
} catch (_) {
|
||||
return const {};
|
||||
}
|
||||
if (!response.isOk) return const {};
|
||||
|
||||
final payload = response.payload;
|
||||
if (payload is! Map) return const {};
|
||||
@@ -65,14 +73,19 @@ class ComplaintsModule {
|
||||
required int reasonId,
|
||||
required int typeId,
|
||||
required List<int> ids,
|
||||
required int parentId,
|
||||
int? parentId,
|
||||
}) async {
|
||||
final response = await api.sendRequest(Opcode.complain, {
|
||||
'reasonId': reasonId,
|
||||
'typeId': typeId,
|
||||
'ids': ids,
|
||||
'parentId': parentId,
|
||||
});
|
||||
final Packet response;
|
||||
try {
|
||||
response = await api.sendRequest(Opcode.complain, {
|
||||
'reasonId': reasonId,
|
||||
'typeId': typeId,
|
||||
'ids': ids,
|
||||
'parentId': ?parentId,
|
||||
}, silent: true);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (!response.isOk) return false;
|
||||
final payload = response.payload;
|
||||
return payload is Map && payload['success'] == true;
|
||||
|
||||
@@ -328,6 +328,70 @@ class ContactsModule {
|
||||
return true;
|
||||
}
|
||||
|
||||
static final Set<int> _blockedIds = <int>{};
|
||||
static bool _blockedLoaded = false;
|
||||
|
||||
static void clearBlockedCache() {
|
||||
_blockedIds.clear();
|
||||
_blockedLoaded = false;
|
||||
}
|
||||
|
||||
static const int _blockedPageSize = 100;
|
||||
static const int _blockedMaxPages = 20;
|
||||
|
||||
static Future<bool> isBlocked(Api api, int contactId) async {
|
||||
if (!_blockedLoaded) await _loadBlockedIds(api);
|
||||
return _blockedIds.contains(contactId);
|
||||
}
|
||||
|
||||
static Future<void> _loadBlockedIds(Api api) async {
|
||||
final ids = <int>{};
|
||||
try {
|
||||
for (var page = 0; page < _blockedMaxPages; page++) {
|
||||
final map = await api.sendRequestMap(Opcode.contactList, {
|
||||
'status': 'BLOCKED',
|
||||
'count': _blockedPageSize,
|
||||
'from': page * _blockedPageSize,
|
||||
});
|
||||
final contacts = map?['contacts'];
|
||||
if (contacts is! List) return;
|
||||
ids.addAll(
|
||||
contacts.whereType<Map>().map((c) => c['id']).whereType<int>(),
|
||||
);
|
||||
if (contacts.length < _blockedPageSize) break;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('Не удалось получить список заблокированных: $e');
|
||||
return;
|
||||
}
|
||||
_blockedIds
|
||||
..clear()
|
||||
..addAll(ids);
|
||||
_blockedLoaded = true;
|
||||
}
|
||||
|
||||
static Future<bool> setBlocked(Api api, int contactId, bool blocked) async {
|
||||
try {
|
||||
final packet = await api.sendRequest(Opcode.contactUpdate, {
|
||||
'contactId': contactId,
|
||||
'action': blocked ? 'BLOCK' : 'UNBLOCK',
|
||||
});
|
||||
if (packet.isError) return false;
|
||||
} catch (e) {
|
||||
logger.w('setBlocked $contactId: $e');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blocked) {
|
||||
_blockedIds.add(contactId);
|
||||
} else {
|
||||
_blockedIds.remove(contactId);
|
||||
}
|
||||
ContactInfoFetch.invalidate(contactId);
|
||||
revision.value++;
|
||||
return true;
|
||||
}
|
||||
|
||||
static Future<void> syncFromLoginPayload(
|
||||
Map<dynamic, dynamic> data,
|
||||
int accountId,
|
||||
|
||||
@@ -56,9 +56,12 @@ String messageFromErrorPayload(dynamic payload) {
|
||||
if (msg == 'FAIL_WRONG_PASSWORD' || msg == 'FAIL_LOGIN_TOKEN') {
|
||||
return 'Ваш токен был отклонён сервером, хм... Попробуйте войти ещё раз.';
|
||||
}
|
||||
for (final key in ['localizedMessage', 'message', 'title']) {
|
||||
for (final key in ['localizedMessage', 'title', 'message']) {
|
||||
final v = payload[key];
|
||||
if (v is String && v.trim().isNotEmpty) return v.trim();
|
||||
if (v is! String) continue;
|
||||
final text = v.trim();
|
||||
if (text.isEmpty || _isRawServerTemplate(text)) continue;
|
||||
return text;
|
||||
}
|
||||
return 'Неизвестная ошибка';
|
||||
}
|
||||
@@ -67,6 +70,9 @@ String messageFromErrorPayload(dynamic payload) {
|
||||
return s.isNotEmpty ? s : 'Неизвестная ошибка';
|
||||
}
|
||||
|
||||
bool _isRawServerTemplate(String text) =>
|
||||
text.startsWith('Key: ') || text.startsWith('key: ');
|
||||
|
||||
bool isSessionExpiredPayload(dynamic payload) {
|
||||
return payload is Map &&
|
||||
(payload['message'] == 'FAIL_LOGIN_TOKEN' ||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class RetainOffsetScrollPhysics extends ScrollPhysics {
|
||||
const RetainOffsetScrollPhysics({super.parent, required this.retain});
|
||||
|
||||
final bool Function() retain;
|
||||
|
||||
@override
|
||||
RetainOffsetScrollPhysics applyTo(ScrollPhysics? ancestor) =>
|
||||
RetainOffsetScrollPhysics(parent: buildParent(ancestor), retain: retain);
|
||||
|
||||
@override
|
||||
double adjustPositionForNewDimensions({
|
||||
required ScrollMetrics oldPosition,
|
||||
required ScrollMetrics newPosition,
|
||||
required bool isScrolling,
|
||||
required double velocity,
|
||||
}) {
|
||||
final adjusted = super.adjustPositionForNewDimensions(
|
||||
oldPosition: oldPosition,
|
||||
newPosition: newPosition,
|
||||
isScrolling: isScrolling,
|
||||
velocity: velocity,
|
||||
);
|
||||
if (!retain()) return adjusted;
|
||||
final grown = newPosition.maxScrollExtent - oldPosition.maxScrollExtent;
|
||||
if (grown <= 0) return adjusted;
|
||||
return (adjusted + grown).clamp(
|
||||
newPosition.minScrollExtent,
|
||||
newPosition.maxScrollExtent,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,14 +20,30 @@ class StickerPanelController {
|
||||
|
||||
final VoidCallback onSendTyping;
|
||||
|
||||
static const double _minPanelHeight = 120;
|
||||
|
||||
late final AnimationController anim;
|
||||
final ValueNotifier<bool> showPanel = ValueNotifier(false);
|
||||
final ValueNotifier<bool> panelHold = ValueNotifier(true);
|
||||
double panelHeight = 300;
|
||||
final ValueNotifier<double> panelHeight = ValueNotifier(300);
|
||||
double baseHeight = 300;
|
||||
double maxHeight = 300;
|
||||
Timer? _typingTimer;
|
||||
|
||||
void hide() => showPanel.value = false;
|
||||
|
||||
void setBaseHeight(double value) {
|
||||
if (value < _minPanelHeight) return;
|
||||
baseHeight = value;
|
||||
if (panelHeight.value < value) panelHeight.value = value;
|
||||
}
|
||||
|
||||
void resizeBy(double delta) {
|
||||
final upper = maxHeight < baseHeight ? baseHeight : maxHeight;
|
||||
final next = (panelHeight.value + delta).clamp(baseHeight, upper);
|
||||
if (next != panelHeight.value) panelHeight.value = next;
|
||||
}
|
||||
|
||||
void _onAnimStatus(AnimationStatus status) {
|
||||
final held = status != AnimationStatus.completed;
|
||||
if (panelHold.value != held) panelHold.value = held;
|
||||
@@ -61,5 +77,6 @@ class StickerPanelController {
|
||||
anim.dispose();
|
||||
showPanel.dispose();
|
||||
panelHold.dispose();
|
||||
panelHeight.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,21 @@ class StickerPanelView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final media = MediaQuery.of(context);
|
||||
stickers.maxHeight = media.size.height - media.padding.top - 160;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: stickers.anim,
|
||||
child: LottieHoldScope(
|
||||
isHeld: stickers.panelHold,
|
||||
child: StickerPanel(
|
||||
height: stickers.panelHeight,
|
||||
onStickerTap: onStickerTap,
|
||||
onEmojiTap: onEmojiTap,
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: stickers.panelHeight,
|
||||
builder: (context, height, _) => StickerPanel(
|
||||
height: height,
|
||||
onStickerTap: onStickerTap,
|
||||
onEmojiTap: onEmojiTap,
|
||||
onResize: stickers.resizeBy,
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
|
||||
@@ -4,9 +4,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:komet/main.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../contacts/edit_contact_sheet.dart';
|
||||
import '../../../backend/modules/complaints.dart';
|
||||
import '../../../backend/modules/contacts.dart';
|
||||
import '../../../backend/modules/messages.dart' show ContactCache;
|
||||
import '../../../core/cache/info_cache.dart';
|
||||
import '../../../core/calls/call_controller.dart';
|
||||
import '../../../core/config/app_show_extra_info.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
@@ -17,6 +19,7 @@ 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/custom_notification.dart';
|
||||
import '../../widgets/formatted_message_text.dart';
|
||||
import '../../widgets/reload_on_reconnect.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
@@ -24,9 +27,11 @@ import '../../widgets/komet_avatar.dart';
|
||||
import '../../widgets/profile_hero.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
import '../../../backend/modules/chats.dart';
|
||||
import '../calls/call_screen.dart';
|
||||
import '../contacts/open_contact_profile.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'group_invite_sheets.dart';
|
||||
import 'profile_action_sheets.dart';
|
||||
|
||||
class _MemberInfo {
|
||||
final int id;
|
||||
@@ -69,6 +74,7 @@ class ChatInfoScreen extends StatefulWidget {
|
||||
final int? dialogPeerId;
|
||||
final ChatInfoTab? initialTab;
|
||||
final Object? heroTag;
|
||||
final bool openedFromChat;
|
||||
|
||||
final void Function(String messageId, int time)? onJumpToMessage;
|
||||
|
||||
@@ -81,6 +87,7 @@ class ChatInfoScreen extends StatefulWidget {
|
||||
this.dialogPeerId,
|
||||
this.initialTab,
|
||||
this.heroTag,
|
||||
this.openedFromChat = false,
|
||||
this.onJumpToMessage,
|
||||
});
|
||||
|
||||
@@ -123,6 +130,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
int _mediaChatId = 0;
|
||||
String? _anchorMsgId;
|
||||
|
||||
int _dontDisturbUntil = 0;
|
||||
int _lastEventTime = 0;
|
||||
bool _blocked = false;
|
||||
bool _muteBusy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -137,8 +149,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
AppLocalizations get l10n => AppLocalizations.of(context)!;
|
||||
|
||||
List<String> get _tabs {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final showInfo = AppShowExtraInfo.current.value;
|
||||
switch (widget.chatType) {
|
||||
case 'DIALOG':
|
||||
@@ -193,6 +206,16 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
_chatInfo = info;
|
||||
|
||||
_mediaChatId = (info?.raw['id'] as int?) ?? widget.chatId;
|
||||
|
||||
final cached = await chats.getChat(_myId, _mediaChatId);
|
||||
if (!mounted) return;
|
||||
if (cached.isNotEmpty) {
|
||||
_dontDisturbUntil = cached.first.dontDisturbUntil;
|
||||
_lastEventTime = cached.first.lastEventTime;
|
||||
}
|
||||
final serverEventTime = (info?.raw['lastEventTime'] as int?) ?? 0;
|
||||
if (serverEventTime > _lastEventTime) _lastEventTime = serverEventTime;
|
||||
|
||||
final lastMessage = info?.raw['lastMessage'];
|
||||
if (lastMessage is Map) {
|
||||
_anchorMsgId = lastMessage['id']?.toString();
|
||||
@@ -235,6 +258,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
_presenceStatus = st;
|
||||
_isOnline = st == 1;
|
||||
}
|
||||
|
||||
if (!_isBot && _otherId != _myId) _loadBlockedState(_otherId!);
|
||||
}
|
||||
} else if (info == null) {
|
||||
setState(() => _isLoading = false);
|
||||
@@ -255,6 +280,12 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadBlockedState(int peerId) async {
|
||||
final blocked = await ContactsModule.isBlocked(api, peerId);
|
||||
if (!mounted || blocked == _blocked) return;
|
||||
setState(() => _blocked = blocked);
|
||||
}
|
||||
|
||||
String? _initialTabLabel() {
|
||||
if (widget.initialTab != ChatInfoTab.media) return null;
|
||||
final media = AppLocalizations.of(context)!.chatInfoTabMedia;
|
||||
@@ -478,10 +509,12 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
if (_isLoading)
|
||||
..._loadingBlocks(cs)
|
||||
else ...[
|
||||
Text(
|
||||
_subtitle(),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
SelectionArea(
|
||||
child: Text(
|
||||
_subtitle(),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildActions(cs),
|
||||
@@ -520,34 +553,80 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
Widget _buildMoreButton(ColorScheme cs) {
|
||||
final canEdit = widget.chatType == 'DIALOG' && _isContact;
|
||||
if (!canEdit) {
|
||||
final entries = _moreMenuEntries();
|
||||
if (entries.isEmpty) {
|
||||
return IconButton(
|
||||
icon: Icon(Icons.more_vert, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
onPressed: null,
|
||||
);
|
||||
}
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return PopupMenuButton<String>(
|
||||
return PopupMenuButton<VoidCallback>(
|
||||
icon: Icon(Icons.more_vert, color: cs.onSurface),
|
||||
onSelected: (v) {
|
||||
if (v == 'edit') _openEdit();
|
||||
},
|
||||
onSelected: (action) => action(),
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem<String>(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.edit, size: 20, color: cs.onSurface),
|
||||
const SizedBox(width: 12),
|
||||
Text(l10n.editContactMenu),
|
||||
],
|
||||
for (final entry in entries)
|
||||
PopupMenuItem<VoidCallback>(
|
||||
value: entry.onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
entry.icon,
|
||||
size: 20,
|
||||
color: entry.destructive ? cs.error : cs.onSurface,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
entry.label,
|
||||
style: entry.destructive ? TextStyle(color: cs.error) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<({IconData icon, String label, bool destructive, VoidCallback onTap})>
|
||||
_moreMenuEntries() {
|
||||
if (_isLoading) return const [];
|
||||
final entries =
|
||||
<({IconData icon, String label, bool destructive, VoidCallback onTap})>[];
|
||||
|
||||
if (widget.chatType == 'DIALOG') {
|
||||
if (_isContact) {
|
||||
entries.add((
|
||||
icon: Symbols.edit,
|
||||
label: l10n.editContactMenu,
|
||||
destructive: false,
|
||||
onTap: _openEdit,
|
||||
));
|
||||
}
|
||||
if (!_isBot && _otherId != null && _otherId != _myId) {
|
||||
entries.add((
|
||||
icon: _blocked ? Symbols.lock_open : Symbols.block,
|
||||
label: _blocked ? l10n.chatInfoMenuUnblock : l10n.chatInfoMenuBlock,
|
||||
destructive: !_blocked,
|
||||
onTap: _toggleBlock,
|
||||
));
|
||||
}
|
||||
entries.add((
|
||||
icon: Symbols.delete,
|
||||
label: l10n.chatInfoMenuDeleteChat,
|
||||
destructive: true,
|
||||
onTap: _deleteChat,
|
||||
));
|
||||
}
|
||||
|
||||
entries.add((
|
||||
icon: Symbols.mop,
|
||||
label: l10n.chatInfoMenuClearHistory,
|
||||
destructive: true,
|
||||
onTap: _clearHistory,
|
||||
));
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
Future<void> _openEdit() async {
|
||||
final oneme = _nameEntry('ONEME');
|
||||
final local = _localContact;
|
||||
@@ -611,22 +690,24 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
children: [
|
||||
const SizedBox(width: 36),
|
||||
Flexible(
|
||||
child: ProfileHeroName(
|
||||
tag: widget.heroTag,
|
||||
text: custom,
|
||||
style: nameStyle,
|
||||
child: AnimatedTextSwap(
|
||||
showAlternate: _showRealName,
|
||||
alignment: Alignment.center,
|
||||
alternate: Text(
|
||||
real ?? custom,
|
||||
style: nameStyle,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
child: Text(
|
||||
custom,
|
||||
style: nameStyle,
|
||||
textAlign: TextAlign.center,
|
||||
child: SelectionArea(
|
||||
child: ProfileHeroName(
|
||||
tag: widget.heroTag,
|
||||
text: custom,
|
||||
style: nameStyle,
|
||||
child: AnimatedTextSwap(
|
||||
showAlternate: _showRealName,
|
||||
alignment: Alignment.center,
|
||||
alternate: Text(
|
||||
real ?? custom,
|
||||
style: nameStyle,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
child: Text(
|
||||
custom,
|
||||
style: nameStyle,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -653,7 +734,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
String _subtitle() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
switch (widget.chatType) {
|
||||
case 'DIALOG':
|
||||
if (_peerDeleted) return l10n.chatInfoMemberDeleted;
|
||||
@@ -676,62 +756,56 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildActions(ColorScheme cs) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final List<({IconData icon, String label, VoidCallback? onTap})> btns;
|
||||
bool get _isMuted {
|
||||
if (_dontDisturbUntil == ChatsModule.muteOff) return false;
|
||||
if (_dontDisturbUntil < 0) return true;
|
||||
return _dontDisturbUntil > DateTime.now().millisecondsSinceEpoch;
|
||||
}
|
||||
|
||||
bool get _iAmAdmin {
|
||||
final info = _chatInfo;
|
||||
if (info == null || _myId == 0) return false;
|
||||
return info.isOwner(_myId) || info.isAdmin(_myId);
|
||||
}
|
||||
|
||||
bool get _isGroupOrChannel =>
|
||||
widget.chatType == 'CHAT' || widget.chatType == 'CHANNEL';
|
||||
|
||||
Widget _buildActions(ColorScheme cs) {
|
||||
final muteBtn = (
|
||||
icon: _isMuted ? Icons.notifications_off : Icons.notifications,
|
||||
label: _isMuted
|
||||
? l10n.chatInfoActionMuted
|
||||
: l10n.contactProfileActionSound,
|
||||
onTap: _muteBusy ? null : _toggleMute,
|
||||
);
|
||||
final chatBtn = (
|
||||
icon: Icons.chat_bubble,
|
||||
label: l10n.contactProfileActionChat,
|
||||
onTap: _openChat,
|
||||
);
|
||||
final leaveBtn = (
|
||||
icon: Icons.exit_to_app,
|
||||
label: l10n.chatInfoActionLeave,
|
||||
onTap: _leaveChat,
|
||||
);
|
||||
|
||||
final List<({IconData icon, String label, VoidCallback? onTap})> btns;
|
||||
if (widget.chatType == 'DIALOG') {
|
||||
if (_isBot) {
|
||||
btns = [
|
||||
btns = [
|
||||
chatBtn,
|
||||
muteBtn,
|
||||
if (!_isBot)
|
||||
(
|
||||
icon: Icons.chat_bubble,
|
||||
label: l10n.contactProfileActionChat,
|
||||
onTap: _openChat,
|
||||
icon: Icons.call,
|
||||
label: l10n.contactProfileActionCall,
|
||||
onTap: _confirmAndStartCall,
|
||||
),
|
||||
(
|
||||
icon: Icons.notifications,
|
||||
label: l10n.contactProfileActionSound,
|
||||
onTap: null,
|
||||
),
|
||||
];
|
||||
} else {
|
||||
btns = [
|
||||
(
|
||||
icon: Icons.chat_bubble,
|
||||
label: l10n.contactProfileActionChat,
|
||||
onTap: _openChat,
|
||||
),
|
||||
(
|
||||
icon: Icons.notifications,
|
||||
label: l10n.contactProfileActionSound,
|
||||
onTap: null,
|
||||
),
|
||||
(icon: Icons.call, label: l10n.contactProfileActionCall, onTap: null),
|
||||
];
|
||||
}
|
||||
];
|
||||
} else if (widget.chatType == 'CHANNEL') {
|
||||
btns = [
|
||||
(
|
||||
icon: Icons.notifications,
|
||||
label: l10n.contactProfileActionSound,
|
||||
onTap: null,
|
||||
),
|
||||
(icon: Icons.exit_to_app, label: l10n.chatInfoActionLeave, onTap: null),
|
||||
];
|
||||
btns = [muteBtn, leaveBtn];
|
||||
} else {
|
||||
btns = [
|
||||
(
|
||||
icon: Icons.chat_bubble,
|
||||
label: l10n.contactProfileActionChat,
|
||||
onTap: null,
|
||||
),
|
||||
(
|
||||
icon: Icons.notifications,
|
||||
label: l10n.contactProfileActionSound,
|
||||
onTap: null,
|
||||
),
|
||||
(icon: Icons.exit_to_app, label: l10n.chatInfoActionLeave, onTap: null),
|
||||
];
|
||||
btns = [chatBtn, muteBtn, leaveBtn];
|
||||
}
|
||||
|
||||
return Padding(
|
||||
@@ -748,17 +822,226 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
void _openChat() {
|
||||
if (widget.openedFromChat) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
pushSwipeable(
|
||||
context,
|
||||
(_) => ChatScreen(
|
||||
chatId: widget.chatId,
|
||||
chatId: _mediaChatId,
|
||||
name: widget.name,
|
||||
imageUrl: widget.imageUrl,
|
||||
chatType: 'DIALOG',
|
||||
chatType: widget.chatType,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleMute() async {
|
||||
if (_muteBusy) return;
|
||||
setState(() => _muteBusy = true);
|
||||
final muted = _isMuted;
|
||||
final target = muted ? ChatsModule.muteOff : ChatsModule.muteForever;
|
||||
final error = await chats.setChatMute(
|
||||
api,
|
||||
chatId: _mediaChatId,
|
||||
dontDisturbUntil: target,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_muteBusy = false;
|
||||
if (error == null) _dontDisturbUntil = target;
|
||||
});
|
||||
showCustomNotification(
|
||||
context,
|
||||
error ??
|
||||
(muted ? l10n.chatInfoNotificationsOn : l10n.chatInfoNotificationsOff),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmAndStartCall() async {
|
||||
final peerId = _otherId;
|
||||
if (peerId == null || peerId == _myId) return;
|
||||
|
||||
final choice = await showBlurredConfirm(
|
||||
context,
|
||||
title: l10n.chatInfoCallConfirmTitle,
|
||||
message: l10n.chatInfoCallConfirmMessage(_customName),
|
||||
confirmLabel: l10n.chatInfoConfirmYes,
|
||||
cancelLabel: l10n.chatInfoConfirmNo,
|
||||
);
|
||||
if (!mounted || !choice.confirmed) return;
|
||||
|
||||
final navigator = Navigator.of(context);
|
||||
final avatarUrl = widget.imageUrl.isNotEmpty ? widget.imageUrl : null;
|
||||
final active = CallController.instance.activeSession;
|
||||
if (active != null) {
|
||||
await navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
CallScreen(name: _customName, avatarUrl: avatarUrl, session: active),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final session = await CallController.instance.startOutgoing(peerId);
|
||||
if (!mounted) return;
|
||||
await navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CallScreen(
|
||||
name: _customName,
|
||||
avatarUrl: avatarUrl,
|
||||
session: session,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, l10n.chatInfoCallFailed);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _leaveChat() async {
|
||||
final isChannel = widget.chatType == 'CHANNEL';
|
||||
final choice = await showBlurredConfirm(
|
||||
context,
|
||||
title: isChannel
|
||||
? l10n.chatInfoLeaveChannelTitle
|
||||
: l10n.chatInfoLeaveGroupTitle,
|
||||
message: isChannel
|
||||
? l10n.chatInfoLeaveChannelMessage
|
||||
: l10n.chatInfoLeaveGroupMessage,
|
||||
confirmLabel: l10n.chatInfoLeaveConfirm,
|
||||
cancelLabel: l10n.chatInfoActionCancel,
|
||||
destructive: true,
|
||||
);
|
||||
if (!mounted || !choice.confirmed) return;
|
||||
|
||||
final ok = await chats.leaveChat(api, chatId: _mediaChatId);
|
||||
if (!mounted) return;
|
||||
if (!ok) {
|
||||
showCustomNotification(context, l10n.chatInfoLeaveFailed);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
}
|
||||
|
||||
Future<void> _clearHistory() async {
|
||||
final canClearForAll = _isGroupOrChannel && _iAmAdmin;
|
||||
final choice = await showBlurredConfirm(
|
||||
context,
|
||||
title: l10n.chatInfoClearHistoryTitle,
|
||||
message: l10n.chatInfoClearHistoryMessage,
|
||||
confirmLabel: l10n.chatInfoClearHistoryConfirm,
|
||||
cancelLabel: l10n.chatInfoActionCancel,
|
||||
destructive: true,
|
||||
checkboxLabel: canClearForAll ? l10n.chatInfoClearHistoryForAll : null,
|
||||
);
|
||||
if (!mounted || !choice.confirmed) return;
|
||||
|
||||
final error = await chats.clearHistory(
|
||||
api,
|
||||
chatId: _mediaChatId,
|
||||
lastEventTime: _lastEventTime,
|
||||
forAll: canClearForAll && choice.checked,
|
||||
);
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, error ?? l10n.chatInfoClearHistoryDone);
|
||||
}
|
||||
|
||||
Future<void> _deleteChat() async {
|
||||
final choice = await showBlurredConfirm(
|
||||
context,
|
||||
title: l10n.chatInfoDeleteChatTitle,
|
||||
message: l10n.chatInfoDeleteChatMessage,
|
||||
confirmLabel: l10n.chatInfoDeleteChatConfirm,
|
||||
cancelLabel: l10n.chatInfoActionCancel,
|
||||
destructive: true,
|
||||
);
|
||||
if (!mounted || !choice.confirmed) return;
|
||||
|
||||
final error = await chats.deleteChat(
|
||||
api,
|
||||
chatId: _mediaChatId,
|
||||
lastEventTime: _lastEventTime,
|
||||
forAll: false,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (error != null) {
|
||||
showCustomNotification(context, error);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
}
|
||||
|
||||
Future<void> _toggleBlock() async {
|
||||
final peerId = _otherId;
|
||||
if (peerId == null) return;
|
||||
|
||||
final block = !_blocked;
|
||||
if (block) {
|
||||
final choice = await showBlurredConfirm(
|
||||
context,
|
||||
title: l10n.chatInfoBlockConfirmTitle,
|
||||
message: l10n.chatInfoBlockConfirmMessage(_customName),
|
||||
confirmLabel: l10n.chatInfoConfirmYes,
|
||||
cancelLabel: l10n.chatInfoConfirmNo,
|
||||
destructive: true,
|
||||
);
|
||||
if (!mounted || !choice.confirmed) return;
|
||||
}
|
||||
|
||||
final ok = await ContactsModule.setBlocked(api, peerId, block);
|
||||
if (!mounted) return;
|
||||
if (!ok) {
|
||||
showCustomNotification(context, l10n.chatInfoBlockFailed);
|
||||
return;
|
||||
}
|
||||
setState(() => _blocked = block);
|
||||
showCustomNotification(
|
||||
context,
|
||||
block ? l10n.chatInfoBlockDone : l10n.chatInfoUnblockDone,
|
||||
);
|
||||
if (block) await _openComplaintCard(peerId);
|
||||
}
|
||||
|
||||
Future<void> _openComplaintCard(int peerId) async {
|
||||
if (!mounted) return;
|
||||
await showComplaintCard(
|
||||
context,
|
||||
title: l10n.chatInfoComplaintTitle,
|
||||
subtitle: l10n.chatInfoComplaintSubtitle,
|
||||
sendLabel: l10n.chatInfoComplaintSend,
|
||||
closeLabel: l10n.chatInfoComplaintClose,
|
||||
emptyLabel: l10n.chatInfoComplaintEmpty,
|
||||
loadReasons: () async {
|
||||
final reasons = await ComplaintsModule.reasonsFor(
|
||||
api,
|
||||
ComplaintsModule.userTypeId,
|
||||
);
|
||||
return reasons
|
||||
.map((r) => (id: r.reasonId, title: r.reasonTitle))
|
||||
.toList();
|
||||
},
|
||||
onSend: (reasonId) async {
|
||||
final ok = await ComplaintsModule.sendComplaint(
|
||||
api,
|
||||
reasonId: reasonId,
|
||||
typeId: ComplaintsModule.userTypeId,
|
||||
ids: [peerId],
|
||||
);
|
||||
if (!mounted) return ok;
|
||||
showCustomNotification(
|
||||
context,
|
||||
ok ? l10n.chatInfoComplaintSent : l10n.chatInfoComplaintFailed,
|
||||
);
|
||||
return ok;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _actionBtn(
|
||||
ColorScheme cs,
|
||||
IconData icon,
|
||||
@@ -789,7 +1072,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
Widget _buildPersistentInfo(ColorScheme cs) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final items = <Widget>[];
|
||||
|
||||
if (widget.chatType == 'DIALOG') {
|
||||
@@ -836,9 +1118,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [...items, const SizedBox(height: 16)],
|
||||
return SelectionArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [...items, const SizedBox(height: 16)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -880,7 +1164,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
Widget _linkCard(ColorScheme cs, String link) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return GlossyPill(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
@@ -916,7 +1199,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
Widget _collapsibleDescCard(ColorScheme cs, String desc) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
const int collapsedLines = 3;
|
||||
final isLong = desc.length > 120;
|
||||
|
||||
@@ -1041,7 +1323,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
Widget _tabBody(ColorScheme cs) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (_selectedTab == 'Info') return _buildInfoTabContent(cs);
|
||||
if (_selectedTab == l10n.chatInfoTabMembers) {
|
||||
return _buildMembersTabContent(cs);
|
||||
@@ -1159,7 +1440,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
Widget _buildInfoTabContent(ColorScheme cs) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final items = <Widget>[];
|
||||
|
||||
if (widget.chatType == 'CHAT') {
|
||||
@@ -1173,9 +1453,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
|
||||
items.add(_buildInfoRowsCard(cs));
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: items,
|
||||
return SelectionArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: items,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1220,7 +1502,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
Widget _buildMembersTabContent(ColorScheme cs) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
@@ -1266,7 +1547,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
),
|
||||
);
|
||||
}
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return InkWell(
|
||||
onTap: () => _fetchMembersPage(),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
@@ -1316,7 +1596,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
);
|
||||
|
||||
Widget _memberTile(ColorScheme cs, _MemberInfo member) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final name =
|
||||
member.name ??
|
||||
ContactCache.get(member.id) ??
|
||||
@@ -1436,7 +1715,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
Widget _buildAllInfoRows(ColorScheme cs) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final rows = <({String label, String value})>[];
|
||||
final chat = _chatInfo?.raw;
|
||||
if (chat == null) {
|
||||
@@ -1558,7 +1836,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
List<({String label, String value})> _buildExtraContactRows() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final c = _contactData;
|
||||
if (c == null) return const [];
|
||||
final rows = <({String label, String value})>[];
|
||||
@@ -1613,7 +1890,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
Widget? _trailingFor(String label, ColorScheme cs) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (label != l10n.chatInfoRowId) return null;
|
||||
if (widget.chatType != 'DIALOG') return null;
|
||||
if (_contactData == null) return null;
|
||||
|
||||
@@ -109,6 +109,8 @@ import '../../widgets/liquid_glass.dart';
|
||||
import 'scheduled_messages_screen.dart';
|
||||
import 'chat_encryption_screen.dart';
|
||||
import 'chat_wallpaper_preview_screen.dart';
|
||||
import 'chat/retain_offset_physics.dart';
|
||||
import 'profile_action_sheets.dart';
|
||||
|
||||
class _DateSeparatorItem {
|
||||
final DateTime date;
|
||||
@@ -546,6 +548,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
static const double _avgMessageHeight = 72.0;
|
||||
static const double _historyPrefetchExtent = _avgMessageHeight * 8;
|
||||
static const double _scrollDownRevealExtent = _avgMessageHeight * 30;
|
||||
static const double _scrollDownRevealFactor = 0.6;
|
||||
static const double _scrollDownTeleportFactor = 2.0;
|
||||
static const double _glossyHeaderHeight = 76.0;
|
||||
static const double _glossySearchHeight = 58.0;
|
||||
@@ -596,6 +599,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
late final AnimationController _scrollDownAnimController;
|
||||
late final CurvedAnimation _scrollDownCurved;
|
||||
bool _scrollDownVisible = false;
|
||||
final ValueNotifier<int> _newMessageCount = ValueNotifier(0);
|
||||
bool _clearCountScheduled = false;
|
||||
bool _retainOffsetOnce = false;
|
||||
late final ScrollPhysics _listPhysics = RetainOffsetScrollPhysics(
|
||||
retain: _consumeRetainOffset,
|
||||
);
|
||||
int _listEpoch = 0;
|
||||
final List<({String id, double pixels, double alignment})> _returnStack = [];
|
||||
bool _returningToAnchor = false;
|
||||
@@ -1151,6 +1160,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
chatType: widget.chatType,
|
||||
heroTag: _profileHeroTag,
|
||||
initialTab: initialTab,
|
||||
openedFromChat: true,
|
||||
onJumpToMessage: (chatRoute == null || widget.embedded)
|
||||
? null
|
||||
: (messageId, time) {
|
||||
@@ -1613,6 +1623,52 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_loadGroupSenderNames();
|
||||
}
|
||||
|
||||
bool _consumeRetainOffset() {
|
||||
if (!_retainOffsetOnce) return false;
|
||||
_retainOffsetOnce = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void _retainOffsetForNextLayout() {
|
||||
_retainOffsetOnce = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_retainOffsetOnce = false;
|
||||
});
|
||||
}
|
||||
|
||||
String? _viewportAnchorId() {
|
||||
final listBox = _listKey.currentContext?.findRenderObject();
|
||||
if (listBox is! RenderBox || !listBox.attached) return null;
|
||||
final height = listBox.size.height;
|
||||
for (final message in _messages) {
|
||||
final box = _messageKeys[message.id]?.currentContext?.findRenderObject();
|
||||
if (box is! RenderBox || !box.attached) continue;
|
||||
final dy = box.localToGlobal(Offset.zero, ancestor: listBox).dy;
|
||||
if (dy >= 0 && dy <= height) return message.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _holdScrollAfterAppend(String? anchorId, double? beforeDy) async {
|
||||
if (anchorId == null || beforeDy == null) return;
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (!mounted || !_scrollController.hasClients) return;
|
||||
|
||||
final afterDy = _messageOffsetInList(anchorId);
|
||||
if (afterDy == null) return;
|
||||
final delta = beforeDy - afterDy;
|
||||
if (delta.abs() <= 0.5) return;
|
||||
|
||||
final pos = _scrollController.position;
|
||||
if (pos.userScrollDirection != ScrollDirection.idle) return;
|
||||
final target = (pos.pixels + delta).clamp(
|
||||
pos.minScrollExtent,
|
||||
pos.maxScrollExtent,
|
||||
);
|
||||
if ((target - pos.pixels).abs() <= 0.5) return;
|
||||
_scrollController.jumpTo(target);
|
||||
}
|
||||
|
||||
double? _messageOffsetInList(String messageId) {
|
||||
final listBox = _listKey.currentContext?.findRenderObject();
|
||||
final box = _keyForMessage(messageId).currentContext?.findRenderObject();
|
||||
@@ -1863,11 +1919,19 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (comment.senderId == _myId) return;
|
||||
if (_messages.any((m) => m.id == comment.id)) return;
|
||||
final nearBottom = _isNearListBottom();
|
||||
final anchorId = nearBottom ? null : _viewportAnchorId();
|
||||
final anchorDy = anchorId == null ? null : _messageOffsetInList(anchorId);
|
||||
if (!nearBottom) _retainOffsetForNextLayout();
|
||||
_messages.add(comment);
|
||||
_syncReactionNotifiersFromMessages();
|
||||
_bumpMessages();
|
||||
unawaited(_resolveCommentNames([comment]));
|
||||
if (nearBottom) _scrollToBottom();
|
||||
if (nearBottom) {
|
||||
_scrollToBottom();
|
||||
} else {
|
||||
_noteMissedMessage();
|
||||
unawaited(_holdScrollAfterAppend(anchorId, anchorDy));
|
||||
}
|
||||
}
|
||||
|
||||
bool _isNearListBottom() {
|
||||
@@ -1959,6 +2023,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_floatingDate.dispose();
|
||||
_scrollDownCurved.dispose();
|
||||
_scrollDownAnimController.dispose();
|
||||
_newMessageCount.dispose();
|
||||
_hasText.dispose();
|
||||
_scheduledCount.dispose();
|
||||
_showAttachmentPanel.removeListener(_onAttachPanelToggle);
|
||||
@@ -2885,6 +2950,11 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (message.senderId == _myId) return;
|
||||
if (_messages.any((m) => m.id == message.id)) return;
|
||||
final nearBottom = _isNearBottom();
|
||||
final anchorId = nearBottom ? null : _viewportAnchorId();
|
||||
final anchorDy = anchorId == null
|
||||
? null
|
||||
: _messageOffsetInList(anchorId);
|
||||
if (!nearBottom) _retainOffsetForNextLayout();
|
||||
_lastSentId = message.id;
|
||||
_messages.add(message);
|
||||
_bumpMessages();
|
||||
@@ -2894,6 +2964,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_scrollToBottom();
|
||||
_scheduleReadMarker();
|
||||
} else {
|
||||
_noteMissedMessage();
|
||||
unawaited(_holdScrollAfterAppend(anchorId, anchorDy));
|
||||
_reapplyPinIfNeeded();
|
||||
}
|
||||
_prank.checkTrigger(message);
|
||||
@@ -3335,20 +3407,27 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
Future<void> _clearHistory() async {
|
||||
final confirmed = await showConfirmDialog(
|
||||
final current = chat;
|
||||
final canClearForAll =
|
||||
(widget.chatType == 'CHAT' || widget.chatType == 'CHANNEL') &&
|
||||
(current?.iAmAdmin(_myId) ?? false);
|
||||
final choice = await showBlurredConfirm(
|
||||
context,
|
||||
title: 'Очистить историю',
|
||||
message:
|
||||
'Все сообщения в этом чате будут удалены без возможности '
|
||||
'восстановления.',
|
||||
confirmLabel: 'Очистить',
|
||||
cancelLabel: 'Отмена',
|
||||
destructive: true,
|
||||
checkboxLabel: canClearForAll ? 'Для всех' : null,
|
||||
);
|
||||
if (!mounted || !confirmed) return;
|
||||
if (!mounted || !choice.confirmed) return;
|
||||
final err = await chats.clearHistory(
|
||||
api,
|
||||
chatId: widget.chatId,
|
||||
lastEventTime: chat?.lastEventTime ?? 0,
|
||||
lastEventTime: current?.lastEventTime ?? 0,
|
||||
forAll: canClearForAll && choice.checked,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (err != null) {
|
||||
@@ -4191,6 +4270,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
void _scrollToBottom() {
|
||||
_returnStack.clear();
|
||||
_newMessageCount.value = 0;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final pos = _scrollController.position;
|
||||
@@ -4221,15 +4301,30 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
void _updateScrollDownVisible() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
if (!_scrollController.hasClients) {
|
||||
_setScrollDownVisible(_newMessageCount.value > 0);
|
||||
return;
|
||||
}
|
||||
final pos = _scrollController.position;
|
||||
final atBottom = _isNearBottom();
|
||||
if (_returnStack.isNotEmpty &&
|
||||
_isNearBottom() &&
|
||||
atBottom &&
|
||||
pos.userScrollDirection != ScrollDirection.idle) {
|
||||
_returnStack.clear();
|
||||
}
|
||||
final show =
|
||||
pos.pixels >= _scrollDownRevealExtent || _returnStack.isNotEmpty;
|
||||
if (atBottom && _newMessageCount.value > 0) _clearNewMessageCountSoon();
|
||||
final reveal = math.min(
|
||||
_scrollDownRevealExtent,
|
||||
pos.viewportDimension * _scrollDownRevealFactor,
|
||||
);
|
||||
_setScrollDownVisible(
|
||||
pos.pixels >= reveal ||
|
||||
_returnStack.isNotEmpty ||
|
||||
_newMessageCount.value > 0,
|
||||
);
|
||||
}
|
||||
|
||||
void _setScrollDownVisible(bool show) {
|
||||
if (show == _scrollDownVisible) return;
|
||||
_scrollDownVisible = show;
|
||||
if (show) {
|
||||
@@ -4239,6 +4334,22 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
}
|
||||
|
||||
void _noteMissedMessage() {
|
||||
_newMessageCount.value++;
|
||||
_updateScrollDownVisible();
|
||||
}
|
||||
|
||||
void _clearNewMessageCountSoon() {
|
||||
if (_clearCountScheduled) return;
|
||||
_clearCountScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_clearCountScheduled = false;
|
||||
if (!mounted || !_isNearBottom()) return;
|
||||
_newMessageCount.value = 0;
|
||||
_updateScrollDownVisible();
|
||||
});
|
||||
}
|
||||
|
||||
void _pushReturnAnchor(String messageId) {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final listBox = _listKey.currentContext?.findRenderObject();
|
||||
@@ -5332,6 +5443,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
return CustomScrollView(
|
||||
controller: _scrollController,
|
||||
reverse: true,
|
||||
physics: _listPhysics,
|
||||
cacheExtent: cacheExtent,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
@@ -5627,27 +5739,67 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
child: SizedBox(
|
||||
width: 46,
|
||||
height: 46,
|
||||
child: GlossyPill(
|
||||
color: frosted || _liquidChrome ? AppFrost.pillTint(cs) : null,
|
||||
blurSigma: frosted && !_liquidChrome ? AppFrost.sigma : null,
|
||||
liquid: _liquidChrome,
|
||||
backdropKey: _pillBackdrop,
|
||||
elevated: true,
|
||||
onTap: _onScrollDownTap,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Symbols.keyboard_arrow_down,
|
||||
color: cs.onSurface,
|
||||
weight: 500,
|
||||
size: 26,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GlossyPill(
|
||||
color: frosted || _liquidChrome
|
||||
? AppFrost.pillTint(cs)
|
||||
: null,
|
||||
blurSigma: frosted && !_liquidChrome ? AppFrost.sigma : null,
|
||||
liquid: _liquidChrome,
|
||||
backdropKey: _pillBackdrop,
|
||||
elevated: true,
|
||||
onTap: _onScrollDownTap,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Symbols.keyboard_arrow_down,
|
||||
color: cs.onSurface,
|
||||
weight: 500,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: -5,
|
||||
right: -3,
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _newMessageCount,
|
||||
builder: (context, count, _) =>
|
||||
count <= 0 ? const SizedBox.shrink() : _unreadBadge(cs, count),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _unreadBadge(ColorScheme cs, int count) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minWidth: 21),
|
||||
height: 21,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
child: Text(
|
||||
count > 99 ? '99+' : '$count',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimary,
|
||||
fontSize: 12,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Uint8List _buildWave(List<double> amps, {int bars = 80}) {
|
||||
final out = Uint8List(bars);
|
||||
if (amps.isEmpty) return out;
|
||||
@@ -6303,13 +6455,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
|
||||
_keyboardBeforeStickers = keyboard > 120 || _messageFocusNode.hasFocus;
|
||||
if (keyboard > 120) _stickers.panelHeight = keyboard;
|
||||
if (keyboard > 120) _stickers.setBaseHeight(keyboard);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
_stickers.showPanel.value = true;
|
||||
}
|
||||
|
||||
Future<void> _sendSticker(StickerItem sticker) async {
|
||||
_stickers.hide();
|
||||
await _sendAttachMessage([
|
||||
StickerAttachment(
|
||||
stickerId: sticker.id.toString(),
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../contacts/contact_sheet_common.dart';
|
||||
|
||||
class ConfirmChoice {
|
||||
final bool confirmed;
|
||||
final bool checked;
|
||||
|
||||
const ConfirmChoice({required this.confirmed, required this.checked});
|
||||
|
||||
static const cancelled = ConfirmChoice(confirmed: false, checked: false);
|
||||
}
|
||||
|
||||
Future<ConfirmChoice> showBlurredConfirm(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
required String confirmLabel,
|
||||
required String cancelLabel,
|
||||
bool destructive = false,
|
||||
String? checkboxLabel,
|
||||
bool checkboxInitial = false,
|
||||
}) async {
|
||||
final result = await showBlurredCard<ConfirmChoice>(
|
||||
context,
|
||||
(_) => _ConfirmCard(
|
||||
title: title,
|
||||
message: message,
|
||||
confirmLabel: confirmLabel,
|
||||
cancelLabel: cancelLabel,
|
||||
destructive: destructive,
|
||||
checkboxLabel: checkboxLabel,
|
||||
checkboxInitial: checkboxInitial,
|
||||
),
|
||||
);
|
||||
return result ?? ConfirmChoice.cancelled;
|
||||
}
|
||||
|
||||
Future<void> showComplaintCard(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required String sendLabel,
|
||||
required String closeLabel,
|
||||
required String emptyLabel,
|
||||
required Future<List<({int id, String title})>> Function() loadReasons,
|
||||
required Future<bool> Function(int reasonId) onSend,
|
||||
}) {
|
||||
return showBlurredCard<void>(
|
||||
context,
|
||||
(_) => _ComplaintCard(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
sendLabel: sendLabel,
|
||||
closeLabel: closeLabel,
|
||||
emptyLabel: emptyLabel,
|
||||
loadReasons: loadReasons,
|
||||
onSend: onSend,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _CardShell extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _CardShell({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
width: width > 420 ? 380 : double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 16),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConfirmCard extends StatefulWidget {
|
||||
final String title;
|
||||
final String message;
|
||||
final String confirmLabel;
|
||||
final String cancelLabel;
|
||||
final bool destructive;
|
||||
final String? checkboxLabel;
|
||||
final bool checkboxInitial;
|
||||
|
||||
const _ConfirmCard({
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.confirmLabel,
|
||||
required this.cancelLabel,
|
||||
required this.destructive,
|
||||
required this.checkboxLabel,
|
||||
required this.checkboxInitial,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ConfirmCard> createState() => _ConfirmCardState();
|
||||
}
|
||||
|
||||
class _ConfirmCardState extends State<_ConfirmCard> {
|
||||
late bool _checked = widget.checkboxInitial;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final checkboxLabel = widget.checkboxLabel;
|
||||
|
||||
return _CardShell(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.message,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
if (checkboxLabel != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => setState(() => _checked = !_checked),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _checked,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
onChanged: (v) => setState(() => _checked = v ?? false),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
checkboxLabel,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(ConfirmChoice.cancelled),
|
||||
child: Text(
|
||||
widget.cancelLabel,
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.tonal(
|
||||
style: widget.destructive
|
||||
? FilledButton.styleFrom(
|
||||
backgroundColor: cs.errorContainer,
|
||||
foregroundColor: cs.onErrorContainer,
|
||||
)
|
||||
: null,
|
||||
onPressed: () => Navigator.of(context).pop(
|
||||
ConfirmChoice(confirmed: true, checked: _checked),
|
||||
),
|
||||
child: Text(widget.confirmLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ComplaintCard extends StatefulWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String sendLabel;
|
||||
final String closeLabel;
|
||||
final String emptyLabel;
|
||||
final Future<List<({int id, String title})>> Function() loadReasons;
|
||||
final Future<bool> Function(int reasonId) onSend;
|
||||
|
||||
const _ComplaintCard({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.sendLabel,
|
||||
required this.closeLabel,
|
||||
required this.emptyLabel,
|
||||
required this.loadReasons,
|
||||
required this.onSend,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ComplaintCard> createState() => _ComplaintCardState();
|
||||
}
|
||||
|
||||
class _ComplaintCardState extends State<_ComplaintCard> {
|
||||
List<({int id, String title})>? _reasons;
|
||||
int? _selected;
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
List<({int id, String title})> loaded;
|
||||
try {
|
||||
loaded = await widget.loadReasons();
|
||||
} catch (_) {
|
||||
loaded = const [];
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _reasons = loaded);
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final reasonId = _selected;
|
||||
if (reasonId == null || _sending) return;
|
||||
setState(() => _sending = true);
|
||||
final ok = await widget.onSend(reasonId);
|
||||
if (!mounted) return;
|
||||
if (ok) {
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
setState(() => _sending = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final reasons = _reasons;
|
||||
|
||||
return _CardShell(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
widget.subtitle,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (reasons == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 28),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (reasons.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 22),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.emptyLabel,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.42,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: RadioGroup<int>(
|
||||
groupValue: _selected,
|
||||
onChanged: (v) {
|
||||
if (_sending) return;
|
||||
setState(() => _selected = v);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final reason in reasons)
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: _sending
|
||||
? null
|
||||
: () => setState(() => _selected = reason.id),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Radio<int>(
|
||||
value: reason.id,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
reason.title,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _sending ? null : () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
widget.closeLabel,
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.tonal(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: cs.errorContainer,
|
||||
foregroundColor: cs.onErrorContainer,
|
||||
),
|
||||
onPressed: _selected == null || _sending ? null : _send,
|
||||
child: _sending
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(widget.sendLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,17 +12,14 @@ Future<T?> showBlurredCard<T>(
|
||||
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.28),
|
||||
transitionDuration: const Duration(milliseconds: 260),
|
||||
pageBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
transitionBuilder: (_, anim, _, _) {
|
||||
pageBuilder: (_, _, _) => builder(context),
|
||||
transitionBuilder: (_, anim, _, child) {
|
||||
final t = Curves.easeOutCubic.transform(anim.value);
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 14 * t, sigmaY: 14 * t),
|
||||
child: Opacity(
|
||||
opacity: anim.value,
|
||||
child: Transform.scale(
|
||||
scale: 0.94 + 0.06 * t,
|
||||
child: builder(context),
|
||||
),
|
||||
child: Transform.scale(scale: 0.94 + 0.06 * t, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -73,6 +73,85 @@ class _RenderZeroIntrinsicWidth extends RenderProxyBox {
|
||||
double computeMaxIntrinsicWidth(double height) => 0;
|
||||
}
|
||||
|
||||
class _HeaderAboveMatchWidth extends MultiChildRenderObjectWidget {
|
||||
_HeaderAboveMatchWidth({required Widget content, required Widget header})
|
||||
: super(children: [content, header]);
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) =>
|
||||
_RenderHeaderAboveMatchWidth();
|
||||
}
|
||||
|
||||
class _HeaderAboveMatchWidthParentData extends ContainerBoxParentData<RenderBox> {}
|
||||
|
||||
class _RenderHeaderAboveMatchWidth extends RenderBox
|
||||
with
|
||||
ContainerRenderObjectMixin<RenderBox, _HeaderAboveMatchWidthParentData>,
|
||||
RenderBoxContainerDefaultsMixin<
|
||||
RenderBox,
|
||||
_HeaderAboveMatchWidthParentData
|
||||
> {
|
||||
@override
|
||||
void setupParentData(RenderBox child) {
|
||||
if (child.parentData is! _HeaderAboveMatchWidthParentData) {
|
||||
child.parentData = _HeaderAboveMatchWidthParentData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) =>
|
||||
firstChild!.getMinIntrinsicWidth(height);
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicWidth(double height) =>
|
||||
firstChild!.getMaxIntrinsicWidth(height);
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) =>
|
||||
firstChild!.getMinIntrinsicHeight(width) +
|
||||
lastChild!.getMinIntrinsicHeight(width);
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) =>
|
||||
firstChild!.getMaxIntrinsicHeight(width) +
|
||||
lastChild!.getMaxIntrinsicHeight(width);
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
final RenderBox content = firstChild!;
|
||||
final RenderBox header = childAfter(content)!;
|
||||
|
||||
content.layout(constraints.loosen(), parentUsesSize: true);
|
||||
final double width = constraints.constrainWidth(content.size.width);
|
||||
|
||||
header.layout(
|
||||
BoxConstraints.tightFor(width: width).enforce(constraints.loosen()),
|
||||
parentUsesSize: true,
|
||||
);
|
||||
|
||||
(header.parentData! as _HeaderAboveMatchWidthParentData).offset =
|
||||
Offset.zero;
|
||||
(content.parentData! as _HeaderAboveMatchWidthParentData).offset = Offset(
|
||||
0,
|
||||
header.size.height,
|
||||
);
|
||||
|
||||
size = constraints.constrain(
|
||||
Size(width, header.size.height + content.size.height),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
defaultPaint(context, offset);
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
||||
return defaultHitTestChildren(result, position: position);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stacks [bottom] directly beneath [top] and forces [bottom] to take exactly
|
||||
/// [top]'s rendered width. Used to keep an inline keyboard and a comments footer
|
||||
/// pinned to their post's natural width instead of stretching to the bubble max
|
||||
@@ -872,11 +951,20 @@ class MessageBubble extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
?senderHeader,
|
||||
if (reply != null) ...[
|
||||
_buildReplyQuote(context, cs, textColor, reply),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
contentWithReactions,
|
||||
if (reply == null)
|
||||
contentWithReactions
|
||||
else
|
||||
_HeaderAboveMatchWidth(
|
||||
content: contentWithReactions,
|
||||
header: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: padding == EdgeInsets.zero ? 8 : 0,
|
||||
right: padding == EdgeInsets.zero ? 8 : 0,
|
||||
bottom: 4,
|
||||
),
|
||||
child: _buildReplyQuote(context, cs, textColor, reply),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -50,12 +50,14 @@ class StickerPanel extends StatefulWidget {
|
||||
final double height;
|
||||
final void Function(StickerItem sticker) onStickerTap;
|
||||
final void Function(Animoji animoji)? onEmojiTap;
|
||||
final void Function(double delta)? onResize;
|
||||
|
||||
const StickerPanel({
|
||||
super.key,
|
||||
required this.height,
|
||||
required this.onStickerTap,
|
||||
this.onEmojiTap,
|
||||
this.onResize,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -68,6 +70,7 @@ class _StickerPanelState extends State<StickerPanel>
|
||||
static const double _headerHeight = 34;
|
||||
static const double _searchFieldHeight = 50;
|
||||
static const double _toggleBarHeight = 48;
|
||||
static const double _resizeHandleHeight = 16;
|
||||
static const int _modeEmoji = 0;
|
||||
static const int _modeStickers = 1;
|
||||
static const String _modePrefKey = 'komet_panel_mode';
|
||||
@@ -257,6 +260,7 @@ class _StickerPanelState extends State<StickerPanel>
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.onResize != null) _buildResizeHandle(cs),
|
||||
Expanded(
|
||||
child: _mode == _modeEmoji && widget.onEmojiTap != null
|
||||
? EmojiPanel(onEmojiTap: widget.onEmojiTap!)
|
||||
@@ -270,6 +274,26 @@ class _StickerPanelState extends State<StickerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResizeHandle(ColorScheme cs) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragUpdate: (details) => widget.onResize!(-details.delta.dy),
|
||||
child: SizedBox(
|
||||
height: _resizeHandleHeight,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 38,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStickerBody(ColorScheme cs) {
|
||||
if (_loading) return Center(child: SmallSpinner());
|
||||
if (_error != null || _sections.isEmpty) {
|
||||
|
||||
@@ -601,6 +601,59 @@
|
||||
"sharedCopyLink": "Copy link",
|
||||
"sharedLinkCopied": "Link copied",
|
||||
"chatInfoActionLeave": "Leave",
|
||||
"chatInfoActionMuted": "Muted",
|
||||
"chatInfoNotificationsOn": "Notifications on",
|
||||
"chatInfoNotificationsOff": "Notifications off",
|
||||
"chatInfoMenuBlock": "Block",
|
||||
"chatInfoMenuUnblock": "Unblock",
|
||||
"chatInfoMenuDeleteChat": "Delete chat",
|
||||
"chatInfoMenuClearHistory": "Clear history",
|
||||
"chatInfoClearHistoryTitle": "Clear history",
|
||||
"chatInfoClearHistoryMessage": "All messages in this chat will be deleted permanently.",
|
||||
"chatInfoClearHistoryForAll": "For everyone",
|
||||
"chatInfoClearHistoryConfirm": "Clear",
|
||||
"chatInfoClearHistoryDone": "History cleared",
|
||||
"chatInfoDeleteChatTitle": "Delete chat",
|
||||
"chatInfoDeleteChatMessage": "The chat will be deleted together with the whole conversation.",
|
||||
"chatInfoDeleteChatConfirm": "Delete",
|
||||
"chatInfoLeaveGroupTitle": "Leave group",
|
||||
"chatInfoLeaveGroupMessage": "You will no longer receive messages from this group.",
|
||||
"chatInfoLeaveChannelTitle": "Leave channel",
|
||||
"chatInfoLeaveChannelMessage": "You will no longer receive posts from this channel.",
|
||||
"chatInfoLeaveConfirm": "Leave",
|
||||
"chatInfoLeaveFailed": "Could not leave the chat",
|
||||
"chatInfoCallConfirmTitle": "Start a call",
|
||||
"chatInfoCallConfirmMessage": "Call {name}?",
|
||||
"@chatInfoCallConfirmMessage": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chatInfoConfirmYes": "Yes",
|
||||
"chatInfoConfirmNo": "No",
|
||||
"chatInfoCallFailed": "Could not start the call",
|
||||
"chatInfoBlockConfirmTitle": "Block",
|
||||
"chatInfoBlockConfirmMessage": "Are you sure you want to block {name}?",
|
||||
"@chatInfoBlockConfirmMessage": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chatInfoBlockDone": "User blocked",
|
||||
"chatInfoUnblockDone": "User unblocked",
|
||||
"chatInfoBlockFailed": "Could not change the block state",
|
||||
"chatInfoComplaintTitle": "Report",
|
||||
"chatInfoComplaintSubtitle": "Choose a reason for the report",
|
||||
"chatInfoComplaintSend": "Report",
|
||||
"chatInfoComplaintClose": "Close",
|
||||
"chatInfoComplaintEmpty": "Could not load the report reasons",
|
||||
"chatInfoComplaintSent": "Report sent",
|
||||
"chatInfoComplaintFailed": "Could not send the report",
|
||||
"chatInfoActionCancel": "Cancel",
|
||||
"chatInfoBio": "About",
|
||||
"chatInfoInviteLink": "Invite link",
|
||||
"chatInfoCollapse": "Collapse",
|
||||
|
||||
@@ -2702,6 +2702,240 @@ abstract class AppLocalizations {
|
||||
/// **'Leave'**
|
||||
String get chatInfoActionLeave;
|
||||
|
||||
/// No description provided for @chatInfoActionMuted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Muted'**
|
||||
String get chatInfoActionMuted;
|
||||
|
||||
/// No description provided for @chatInfoNotificationsOn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Notifications on'**
|
||||
String get chatInfoNotificationsOn;
|
||||
|
||||
/// No description provided for @chatInfoNotificationsOff.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Notifications off'**
|
||||
String get chatInfoNotificationsOff;
|
||||
|
||||
/// No description provided for @chatInfoMenuBlock.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Block'**
|
||||
String get chatInfoMenuBlock;
|
||||
|
||||
/// No description provided for @chatInfoMenuUnblock.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unblock'**
|
||||
String get chatInfoMenuUnblock;
|
||||
|
||||
/// No description provided for @chatInfoMenuDeleteChat.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete chat'**
|
||||
String get chatInfoMenuDeleteChat;
|
||||
|
||||
/// No description provided for @chatInfoMenuClearHistory.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear history'**
|
||||
String get chatInfoMenuClearHistory;
|
||||
|
||||
/// No description provided for @chatInfoClearHistoryTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear history'**
|
||||
String get chatInfoClearHistoryTitle;
|
||||
|
||||
/// No description provided for @chatInfoClearHistoryMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All messages in this chat will be deleted permanently.'**
|
||||
String get chatInfoClearHistoryMessage;
|
||||
|
||||
/// No description provided for @chatInfoClearHistoryForAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'For everyone'**
|
||||
String get chatInfoClearHistoryForAll;
|
||||
|
||||
/// No description provided for @chatInfoClearHistoryConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear'**
|
||||
String get chatInfoClearHistoryConfirm;
|
||||
|
||||
/// No description provided for @chatInfoClearHistoryDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'History cleared'**
|
||||
String get chatInfoClearHistoryDone;
|
||||
|
||||
/// No description provided for @chatInfoDeleteChatTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete chat'**
|
||||
String get chatInfoDeleteChatTitle;
|
||||
|
||||
/// No description provided for @chatInfoDeleteChatMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The chat will be deleted together with the whole conversation.'**
|
||||
String get chatInfoDeleteChatMessage;
|
||||
|
||||
/// No description provided for @chatInfoDeleteChatConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get chatInfoDeleteChatConfirm;
|
||||
|
||||
/// No description provided for @chatInfoLeaveGroupTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Leave group'**
|
||||
String get chatInfoLeaveGroupTitle;
|
||||
|
||||
/// No description provided for @chatInfoLeaveGroupMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You will no longer receive messages from this group.'**
|
||||
String get chatInfoLeaveGroupMessage;
|
||||
|
||||
/// No description provided for @chatInfoLeaveChannelTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Leave channel'**
|
||||
String get chatInfoLeaveChannelTitle;
|
||||
|
||||
/// No description provided for @chatInfoLeaveChannelMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You will no longer receive posts from this channel.'**
|
||||
String get chatInfoLeaveChannelMessage;
|
||||
|
||||
/// No description provided for @chatInfoLeaveConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Leave'**
|
||||
String get chatInfoLeaveConfirm;
|
||||
|
||||
/// No description provided for @chatInfoLeaveFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not leave the chat'**
|
||||
String get chatInfoLeaveFailed;
|
||||
|
||||
/// No description provided for @chatInfoCallConfirmTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Start a call'**
|
||||
String get chatInfoCallConfirmTitle;
|
||||
|
||||
/// No description provided for @chatInfoCallConfirmMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Call {name}?'**
|
||||
String chatInfoCallConfirmMessage(String name);
|
||||
|
||||
/// No description provided for @chatInfoConfirmYes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Yes'**
|
||||
String get chatInfoConfirmYes;
|
||||
|
||||
/// No description provided for @chatInfoConfirmNo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No'**
|
||||
String get chatInfoConfirmNo;
|
||||
|
||||
/// No description provided for @chatInfoCallFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not start the call'**
|
||||
String get chatInfoCallFailed;
|
||||
|
||||
/// No description provided for @chatInfoBlockConfirmTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Block'**
|
||||
String get chatInfoBlockConfirmTitle;
|
||||
|
||||
/// No description provided for @chatInfoBlockConfirmMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Are you sure you want to block {name}?'**
|
||||
String chatInfoBlockConfirmMessage(String name);
|
||||
|
||||
/// No description provided for @chatInfoBlockDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'User blocked'**
|
||||
String get chatInfoBlockDone;
|
||||
|
||||
/// No description provided for @chatInfoUnblockDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'User unblocked'**
|
||||
String get chatInfoUnblockDone;
|
||||
|
||||
/// No description provided for @chatInfoBlockFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not change the block state'**
|
||||
String get chatInfoBlockFailed;
|
||||
|
||||
/// No description provided for @chatInfoComplaintTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Report'**
|
||||
String get chatInfoComplaintTitle;
|
||||
|
||||
/// No description provided for @chatInfoComplaintSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose a reason for the report'**
|
||||
String get chatInfoComplaintSubtitle;
|
||||
|
||||
/// No description provided for @chatInfoComplaintSend.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Report'**
|
||||
String get chatInfoComplaintSend;
|
||||
|
||||
/// No description provided for @chatInfoComplaintClose.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Close'**
|
||||
String get chatInfoComplaintClose;
|
||||
|
||||
/// No description provided for @chatInfoComplaintEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not load the report reasons'**
|
||||
String get chatInfoComplaintEmpty;
|
||||
|
||||
/// No description provided for @chatInfoComplaintSent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Report sent'**
|
||||
String get chatInfoComplaintSent;
|
||||
|
||||
/// No description provided for @chatInfoComplaintFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not send the report'**
|
||||
String get chatInfoComplaintFailed;
|
||||
|
||||
/// No description provided for @chatInfoActionCancel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cancel'**
|
||||
String get chatInfoActionCancel;
|
||||
|
||||
/// No description provided for @chatInfoBio.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -1393,6 +1393,131 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get chatInfoActionLeave => 'Leave';
|
||||
|
||||
@override
|
||||
String get chatInfoActionMuted => 'Muted';
|
||||
|
||||
@override
|
||||
String get chatInfoNotificationsOn => 'Notifications on';
|
||||
|
||||
@override
|
||||
String get chatInfoNotificationsOff => 'Notifications off';
|
||||
|
||||
@override
|
||||
String get chatInfoMenuBlock => 'Block';
|
||||
|
||||
@override
|
||||
String get chatInfoMenuUnblock => 'Unblock';
|
||||
|
||||
@override
|
||||
String get chatInfoMenuDeleteChat => 'Delete chat';
|
||||
|
||||
@override
|
||||
String get chatInfoMenuClearHistory => 'Clear history';
|
||||
|
||||
@override
|
||||
String get chatInfoClearHistoryTitle => 'Clear history';
|
||||
|
||||
@override
|
||||
String get chatInfoClearHistoryMessage =>
|
||||
'All messages in this chat will be deleted permanently.';
|
||||
|
||||
@override
|
||||
String get chatInfoClearHistoryForAll => 'For everyone';
|
||||
|
||||
@override
|
||||
String get chatInfoClearHistoryConfirm => 'Clear';
|
||||
|
||||
@override
|
||||
String get chatInfoClearHistoryDone => 'History cleared';
|
||||
|
||||
@override
|
||||
String get chatInfoDeleteChatTitle => 'Delete chat';
|
||||
|
||||
@override
|
||||
String get chatInfoDeleteChatMessage =>
|
||||
'The chat will be deleted together with the whole conversation.';
|
||||
|
||||
@override
|
||||
String get chatInfoDeleteChatConfirm => 'Delete';
|
||||
|
||||
@override
|
||||
String get chatInfoLeaveGroupTitle => 'Leave group';
|
||||
|
||||
@override
|
||||
String get chatInfoLeaveGroupMessage =>
|
||||
'You will no longer receive messages from this group.';
|
||||
|
||||
@override
|
||||
String get chatInfoLeaveChannelTitle => 'Leave channel';
|
||||
|
||||
@override
|
||||
String get chatInfoLeaveChannelMessage =>
|
||||
'You will no longer receive posts from this channel.';
|
||||
|
||||
@override
|
||||
String get chatInfoLeaveConfirm => 'Leave';
|
||||
|
||||
@override
|
||||
String get chatInfoLeaveFailed => 'Could not leave the chat';
|
||||
|
||||
@override
|
||||
String get chatInfoCallConfirmTitle => 'Start a call';
|
||||
|
||||
@override
|
||||
String chatInfoCallConfirmMessage(String name) {
|
||||
return 'Call $name?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatInfoConfirmYes => 'Yes';
|
||||
|
||||
@override
|
||||
String get chatInfoConfirmNo => 'No';
|
||||
|
||||
@override
|
||||
String get chatInfoCallFailed => 'Could not start the call';
|
||||
|
||||
@override
|
||||
String get chatInfoBlockConfirmTitle => 'Block';
|
||||
|
||||
@override
|
||||
String chatInfoBlockConfirmMessage(String name) {
|
||||
return 'Are you sure you want to block $name?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatInfoBlockDone => 'User blocked';
|
||||
|
||||
@override
|
||||
String get chatInfoUnblockDone => 'User unblocked';
|
||||
|
||||
@override
|
||||
String get chatInfoBlockFailed => 'Could not change the block state';
|
||||
|
||||
@override
|
||||
String get chatInfoComplaintTitle => 'Report';
|
||||
|
||||
@override
|
||||
String get chatInfoComplaintSubtitle => 'Choose a reason for the report';
|
||||
|
||||
@override
|
||||
String get chatInfoComplaintSend => 'Report';
|
||||
|
||||
@override
|
||||
String get chatInfoComplaintClose => 'Close';
|
||||
|
||||
@override
|
||||
String get chatInfoComplaintEmpty => 'Could not load the report reasons';
|
||||
|
||||
@override
|
||||
String get chatInfoComplaintSent => 'Report sent';
|
||||
|
||||
@override
|
||||
String get chatInfoComplaintFailed => 'Could not send the report';
|
||||
|
||||
@override
|
||||
String get chatInfoActionCancel => 'Cancel';
|
||||
|
||||
@override
|
||||
String get chatInfoBio => 'About';
|
||||
|
||||
|
||||
@@ -1401,6 +1401,131 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get chatInfoActionLeave => 'Покинуть';
|
||||
|
||||
@override
|
||||
String get chatInfoActionMuted => 'Без звука';
|
||||
|
||||
@override
|
||||
String get chatInfoNotificationsOn => 'Уведомления включены';
|
||||
|
||||
@override
|
||||
String get chatInfoNotificationsOff => 'Уведомления отключены';
|
||||
|
||||
@override
|
||||
String get chatInfoMenuBlock => 'Заблокировать';
|
||||
|
||||
@override
|
||||
String get chatInfoMenuUnblock => 'Разблокировать';
|
||||
|
||||
@override
|
||||
String get chatInfoMenuDeleteChat => 'Удалить чат';
|
||||
|
||||
@override
|
||||
String get chatInfoMenuClearHistory => 'Очистить историю';
|
||||
|
||||
@override
|
||||
String get chatInfoClearHistoryTitle => 'Очистить историю';
|
||||
|
||||
@override
|
||||
String get chatInfoClearHistoryMessage =>
|
||||
'Все сообщения в этом чате будут удалены без возможности восстановления.';
|
||||
|
||||
@override
|
||||
String get chatInfoClearHistoryForAll => 'Для всех';
|
||||
|
||||
@override
|
||||
String get chatInfoClearHistoryConfirm => 'Очистить';
|
||||
|
||||
@override
|
||||
String get chatInfoClearHistoryDone => 'История очищена';
|
||||
|
||||
@override
|
||||
String get chatInfoDeleteChatTitle => 'Удалить чат';
|
||||
|
||||
@override
|
||||
String get chatInfoDeleteChatMessage =>
|
||||
'Чат будет удалён вместе со всей перепиской.';
|
||||
|
||||
@override
|
||||
String get chatInfoDeleteChatConfirm => 'Удалить';
|
||||
|
||||
@override
|
||||
String get chatInfoLeaveGroupTitle => 'Покинуть группу';
|
||||
|
||||
@override
|
||||
String get chatInfoLeaveGroupMessage =>
|
||||
'Вы больше не будете получать сообщения этой группы.';
|
||||
|
||||
@override
|
||||
String get chatInfoLeaveChannelTitle => 'Покинуть канал';
|
||||
|
||||
@override
|
||||
String get chatInfoLeaveChannelMessage =>
|
||||
'Вы больше не будете получать публикации этого канала.';
|
||||
|
||||
@override
|
||||
String get chatInfoLeaveConfirm => 'Покинуть';
|
||||
|
||||
@override
|
||||
String get chatInfoLeaveFailed => 'Не удалось покинуть чат';
|
||||
|
||||
@override
|
||||
String get chatInfoCallConfirmTitle => 'Начать звонок';
|
||||
|
||||
@override
|
||||
String chatInfoCallConfirmMessage(String name) {
|
||||
return 'Позвонить $name?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatInfoConfirmYes => 'Да';
|
||||
|
||||
@override
|
||||
String get chatInfoConfirmNo => 'Нет';
|
||||
|
||||
@override
|
||||
String get chatInfoCallFailed => 'Не удалось начать звонок';
|
||||
|
||||
@override
|
||||
String get chatInfoBlockConfirmTitle => 'Заблокировать';
|
||||
|
||||
@override
|
||||
String chatInfoBlockConfirmMessage(String name) {
|
||||
return 'Вы уверены, что хотите заблокировать $name?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatInfoBlockDone => 'Пользователь заблокирован';
|
||||
|
||||
@override
|
||||
String get chatInfoUnblockDone => 'Пользователь разблокирован';
|
||||
|
||||
@override
|
||||
String get chatInfoBlockFailed => 'Не удалось изменить блокировку';
|
||||
|
||||
@override
|
||||
String get chatInfoComplaintTitle => 'Пожаловаться';
|
||||
|
||||
@override
|
||||
String get chatInfoComplaintSubtitle => 'Выберите причину жалобы';
|
||||
|
||||
@override
|
||||
String get chatInfoComplaintSend => 'Пожаловаться';
|
||||
|
||||
@override
|
||||
String get chatInfoComplaintClose => 'Закрыть';
|
||||
|
||||
@override
|
||||
String get chatInfoComplaintEmpty => 'Не удалось загрузить причины жалобы';
|
||||
|
||||
@override
|
||||
String get chatInfoComplaintSent => 'Жалоба отправлена';
|
||||
|
||||
@override
|
||||
String get chatInfoComplaintFailed => 'Не удалось отправить жалобу';
|
||||
|
||||
@override
|
||||
String get chatInfoActionCancel => 'Отмена';
|
||||
|
||||
@override
|
||||
String get chatInfoBio => 'О себе';
|
||||
|
||||
|
||||
@@ -457,6 +457,45 @@
|
||||
"sharedCopyLink": "Копировать ссылку",
|
||||
"sharedLinkCopied": "Ссылка скопирована",
|
||||
"chatInfoActionLeave": "Покинуть",
|
||||
"chatInfoActionMuted": "Без звука",
|
||||
"chatInfoNotificationsOn": "Уведомления включены",
|
||||
"chatInfoNotificationsOff": "Уведомления отключены",
|
||||
"chatInfoMenuBlock": "Заблокировать",
|
||||
"chatInfoMenuUnblock": "Разблокировать",
|
||||
"chatInfoMenuDeleteChat": "Удалить чат",
|
||||
"chatInfoMenuClearHistory": "Очистить историю",
|
||||
"chatInfoClearHistoryTitle": "Очистить историю",
|
||||
"chatInfoClearHistoryMessage": "Все сообщения в этом чате будут удалены без возможности восстановления.",
|
||||
"chatInfoClearHistoryForAll": "Для всех",
|
||||
"chatInfoClearHistoryConfirm": "Очистить",
|
||||
"chatInfoClearHistoryDone": "История очищена",
|
||||
"chatInfoDeleteChatTitle": "Удалить чат",
|
||||
"chatInfoDeleteChatMessage": "Чат будет удалён вместе со всей перепиской.",
|
||||
"chatInfoDeleteChatConfirm": "Удалить",
|
||||
"chatInfoLeaveGroupTitle": "Покинуть группу",
|
||||
"chatInfoLeaveGroupMessage": "Вы больше не будете получать сообщения этой группы.",
|
||||
"chatInfoLeaveChannelTitle": "Покинуть канал",
|
||||
"chatInfoLeaveChannelMessage": "Вы больше не будете получать публикации этого канала.",
|
||||
"chatInfoLeaveConfirm": "Покинуть",
|
||||
"chatInfoLeaveFailed": "Не удалось покинуть чат",
|
||||
"chatInfoCallConfirmTitle": "Начать звонок",
|
||||
"chatInfoCallConfirmMessage": "Позвонить {name}?",
|
||||
"chatInfoConfirmYes": "Да",
|
||||
"chatInfoConfirmNo": "Нет",
|
||||
"chatInfoCallFailed": "Не удалось начать звонок",
|
||||
"chatInfoBlockConfirmTitle": "Заблокировать",
|
||||
"chatInfoBlockConfirmMessage": "Вы уверены, что хотите заблокировать {name}?",
|
||||
"chatInfoBlockDone": "Пользователь заблокирован",
|
||||
"chatInfoUnblockDone": "Пользователь разблокирован",
|
||||
"chatInfoBlockFailed": "Не удалось изменить блокировку",
|
||||
"chatInfoComplaintTitle": "Пожаловаться",
|
||||
"chatInfoComplaintSubtitle": "Выберите причину жалобы",
|
||||
"chatInfoComplaintSend": "Пожаловаться",
|
||||
"chatInfoComplaintClose": "Закрыть",
|
||||
"chatInfoComplaintEmpty": "Не удалось загрузить причины жалобы",
|
||||
"chatInfoComplaintSent": "Жалоба отправлена",
|
||||
"chatInfoComplaintFailed": "Не удалось отправить жалобу",
|
||||
"chatInfoActionCancel": "Отмена",
|
||||
"chatInfoBio": "О себе",
|
||||
"chatInfoInviteLink": "Ссылка-приглашение",
|
||||
"chatInfoCollapse": "Свернуть",
|
||||
|
||||
Reference in New Issue
Block a user