diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 77fc950..ddc2b53 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -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(); } diff --git a/lib/backend/modules/complaints.dart b/lib/backend/modules/complaints.dart index 5ccbb21..6182e8b 100644 --- a/lib/backend/modules/complaints.dart +++ b/lib/backend/modules/complaints.dart @@ -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>? _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 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; diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index edb7ba4..0fed98f 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -328,6 +328,70 @@ class ContactsModule { return true; } + static final Set _blockedIds = {}; + static bool _blockedLoaded = false; + + static void clearBlockedCache() { + _blockedIds.clear(); + _blockedLoaded = false; + } + + static const int _blockedPageSize = 100; + static const int _blockedMaxPages = 20; + + static Future isBlocked(Api api, int contactId) async { + if (!_blockedLoaded) await _loadBlockedIds(api); + return _blockedIds.contains(contactId); + } + + static Future _loadBlockedIds(Api api) async { + final ids = {}; + 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((c) => c['id']).whereType(), + ); + if (contacts.length < _blockedPageSize) break; + } + } catch (e) { + logger.w('Не удалось получить список заблокированных: $e'); + return; + } + _blockedIds + ..clear() + ..addAll(ids); + _blockedLoaded = true; + } + + static Future 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 syncFromLoginPayload( Map data, int accountId, diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index c69dab4..44034ab 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -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' || diff --git a/lib/frontend/screens/chats/chat/retain_offset_physics.dart b/lib/frontend/screens/chats/chat/retain_offset_physics.dart new file mode 100644 index 0000000..a92c664 --- /dev/null +++ b/lib/frontend/screens/chats/chat/retain_offset_physics.dart @@ -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, + ); + } +} diff --git a/lib/frontend/screens/chats/chat/sticker_panel_controller.dart b/lib/frontend/screens/chats/chat/sticker_panel_controller.dart index 4f403a6..4c17e13 100644 --- a/lib/frontend/screens/chats/chat/sticker_panel_controller.dart +++ b/lib/frontend/screens/chats/chat/sticker_panel_controller.dart @@ -20,14 +20,30 @@ class StickerPanelController { final VoidCallback onSendTyping; + static const double _minPanelHeight = 120; + late final AnimationController anim; final ValueNotifier showPanel = ValueNotifier(false); final ValueNotifier panelHold = ValueNotifier(true); - double panelHeight = 300; + final ValueNotifier 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(); } } diff --git a/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart b/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart index 374857f..69795bb 100644 --- a/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart +++ b/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart @@ -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( + valueListenable: stickers.panelHeight, + builder: (context, height, _) => StickerPanel( + height: height, + onStickerTap: onStickerTap, + onEmojiTap: onEmojiTap, + onResize: stickers.resizeBy, + ), ), ), builder: (context, child) { diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 32ac049..ceed797 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -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 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 super.dispose(); } + AppLocalizations get l10n => AppLocalizations.of(context)!; + List 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 _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 _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 } } + Future _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 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 } 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( + return PopupMenuButton( icon: Icon(Icons.more_vert, color: cs.onSurface), - onSelected: (v) { - if (v == 'edit') _openEdit(); - }, + onSelected: (action) => action(), itemBuilder: (_) => [ - PopupMenuItem( - 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( + 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 _openEdit() async { final oneme = _nameEntry('ONEME'); final local = _localContact; @@ -611,22 +690,24 @@ class _ChatInfoScreenState extends State 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 } 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 } } - 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 } 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 _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 _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 _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 _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 _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 _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 _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 } Widget _buildPersistentInfo(ColorScheme cs) { - final l10n = AppLocalizations.of(context)!; final items = []; if (widget.chatType == 'DIALOG') { @@ -836,9 +1118,11 @@ class _ChatInfoScreenState extends State } 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 } 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 } 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 } 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 } Widget _buildInfoTabContent(ColorScheme cs) { - final l10n = AppLocalizations.of(context)!; final items = []; if (widget.chatType == 'CHAT') { @@ -1173,9 +1453,11 @@ class _ChatInfoScreenState extends State 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 } Widget _buildMembersTabContent(ColorScheme cs) { - final l10n = AppLocalizations.of(context)!; return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, @@ -1266,7 +1547,6 @@ class _ChatInfoScreenState extends State ), ); } - final l10n = AppLocalizations.of(context)!; return InkWell( onTap: () => _fetchMembersPage(), borderRadius: BorderRadius.circular(14), @@ -1316,7 +1596,6 @@ class _ChatInfoScreenState extends State ); 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 } 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 } 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 } 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; diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 695b157..36b87c0 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -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 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 late final AnimationController _scrollDownAnimController; late final CurvedAnimation _scrollDownCurved; bool _scrollDownVisible = false; + final ValueNotifier _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 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 _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 _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 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 _floatingDate.dispose(); _scrollDownCurved.dispose(); _scrollDownAnimController.dispose(); + _newMessageCount.dispose(); _hasText.dispose(); _scheduledCount.dispose(); _showAttachmentPanel.removeListener(_onAttachPanelToggle); @@ -2885,6 +2950,11 @@ class _ChatScreenState extends State 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 _scrollToBottom(); _scheduleReadMarker(); } else { + _noteMissedMessage(); + unawaited(_holdScrollAfterAppend(anchorId, anchorDy)); _reapplyPinIfNeeded(); } _prank.checkTrigger(message); @@ -3335,20 +3407,27 @@ class _ChatScreenState extends State } Future _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 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 } 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 } } + 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 return CustomScrollView( controller: _scrollController, reverse: true, + physics: _listPhysics, cacheExtent: cacheExtent, slivers: [ SliverPadding( @@ -5627,27 +5739,67 @@ class _ChatScreenState extends State 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( + 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 amps, {int bars = 80}) { final out = Uint8List(bars); if (amps.isEmpty) return out; @@ -6303,13 +6455,12 @@ class _ChatScreenState extends State } 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 _sendSticker(StickerItem sticker) async { - _stickers.hide(); await _sendAttachMessage([ StickerAttachment( stickerId: sticker.id.toString(), diff --git a/lib/frontend/screens/chats/profile_action_sheets.dart b/lib/frontend/screens/chats/profile_action_sheets.dart new file mode 100644 index 0000000..c9b1cef --- /dev/null +++ b/lib/frontend/screens/chats/profile_action_sheets.dart @@ -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 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( + context, + (_) => _ConfirmCard( + title: title, + message: message, + confirmLabel: confirmLabel, + cancelLabel: cancelLabel, + destructive: destructive, + checkboxLabel: checkboxLabel, + checkboxInitial: checkboxInitial, + ), + ); + return result ?? ConfirmChoice.cancelled; +} + +Future showComplaintCard( + BuildContext context, { + required String title, + required String subtitle, + required String sendLabel, + required String closeLabel, + required String emptyLabel, + required Future> Function() loadReasons, + required Future Function(int reasonId) onSend, +}) { + return showBlurredCard( + 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> Function() loadReasons; + final Future 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 _load() async { + List<({int id, String title})> loaded; + try { + loaded = await widget.loadReasons(); + } catch (_) { + loaded = const []; + } + if (!mounted) return; + setState(() => _reasons = loaded); + } + + Future _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( + 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( + 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), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/frontend/screens/contacts/contact_sheet_common.dart b/lib/frontend/screens/contacts/contact_sheet_common.dart index b2d1a3d..114e7bc 100644 --- a/lib/frontend/screens/contacts/contact_sheet_common.dart +++ b/lib/frontend/screens/contacts/contact_sheet_common.dart @@ -12,17 +12,14 @@ Future showBlurredCard( 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), ), ); }, diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index ef4cab5..f0a9c84 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -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 {} + +class _RenderHeaderAboveMatchWidth extends RenderBox + with + ContainerRenderObjectMixin, + 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), + ), + ), ], ); diff --git a/lib/frontend/widgets/sticker_panel.dart b/lib/frontend/widgets/sticker_panel.dart index 6ae936b..692d268 100644 --- a/lib/frontend/widgets/sticker_panel.dart +++ b/lib/frontend/widgets/sticker_panel.dart @@ -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 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 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 ); } + 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) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f80c00e..f1f0403 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1d20c34..5d8cfee 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 777047c..49d259b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 870a3ef..be0dead 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'О себе'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 100549c..61a9775 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -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": "Свернуть", diff --git a/test/chat_scroll_anchor_test.dart b/test/chat_scroll_anchor_test.dart new file mode 100644 index 0000000..fc8b435 --- /dev/null +++ b/test/chat_scroll_anchor_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/frontend/screens/chats/chat/retain_offset_physics.dart'; + +const double _itemHeight = 60; +const double _newestHeight = 84; +const double _viewportHeight = 300; + +double? _offsetInList(GlobalKey listKey, GlobalKey itemKey) { + final listBox = listKey.currentContext?.findRenderObject(); + final box = itemKey.currentContext?.findRenderObject(); + if (listBox is! RenderBox || box is! RenderBox || !box.attached) return null; + return box.localToGlobal(Offset.zero, ancestor: listBox).dy; +} + +class _Harness { + _Harness(this.tester, {required this.physics}); + + final WidgetTester tester; + final ScrollPhysics? physics; + final GlobalKey listKey = GlobalKey(); + final ScrollController controller = ScrollController(); + final List items = [for (var i = 0; i < 40; i++) 'm$i']; + late final Map keys = { + for (final id in items) id: GlobalKey(), + 'newest': GlobalKey(), + }; + + Future pump() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + key: listKey, + height: _viewportHeight, + child: CustomScrollView( + controller: controller, + reverse: true, + physics: physics, + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (index == 0) return const SizedBox(height: 0); + final id = items[items.length - index]; + return SizedBox( + key: keys[id], + height: id == 'newest' ? _newestHeight : _itemHeight, + child: Text(id), + ); + }, childCount: items.length + 1), + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); + } + + String anchorId() => items.firstWhere((id) { + final dy = _offsetInList(listKey, keys[id]!); + return dy != null && dy >= 0 && dy <= _viewportHeight; + }); + + double dyOf(String id) => _offsetInList(listKey, keys[id]!)!; +} + +void main() { + testWidgets('appending to a reversed list drags the view toward the newest ' + 'message', (tester) async { + final h = _Harness(tester, physics: null); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final beforeDy = h.dyOf(anchor); + + h.items.add('newest'); + await h.pump(); + + expect(h.dyOf(anchor), lessThan(beforeDy - 1)); + expect(h.controller.position.pixels, 600); + }); + + testWidgets('RetainOffsetScrollPhysics holds the view in place when a ' + 'message is appended', (tester) async { + var retainOnce = false; + final h = _Harness( + tester, + physics: RetainOffsetScrollPhysics( + retain: () { + if (!retainOnce) return false; + retainOnce = false; + return true; + }, + ), + ); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final beforeDy = h.dyOf(anchor); + + retainOnce = true; + h.items.add('newest'); + await h.pump(); + + expect(h.dyOf(anchor), closeTo(beforeDy, 0.5)); + expect(h.controller.position.pixels, greaterThan(600)); + }); + + testWidgets('RetainOffsetScrollPhysics stays inert while the flag is unset', ( + tester, + ) async { + final h = _Harness( + tester, + physics: RetainOffsetScrollPhysics(retain: () => false), + ); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final beforeDy = h.dyOf(anchor); + + h.items.add('newest'); + await h.pump(); + + expect(h.dyOf(anchor), lessThan(beforeDy - 1)); + expect(h.controller.position.pixels, 600); + }); +} diff --git a/test/message_bubble_layout_test.dart b/test/message_bubble_layout_test.dart index 4e9802b..4f2af64 100644 --- a/test/message_bubble_layout_test.dart +++ b/test/message_bubble_layout_test.dart @@ -3,9 +3,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:komet/backend/modules/messages.dart'; import 'package:komet/frontend/widgets/message_bubble.dart'; import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/models/attachment.dart'; const int _me = 1; const int _peer = 7; +const double _photoWidth = 180; CachedMessage _message({ required String text, @@ -35,6 +37,36 @@ CachedMessage _message({ : null, ); +CachedMessage _photoReply() => CachedMessage( + id: '1', + accountId: _me, + chatId: 2, + senderId: _peer, + text: 'Вот те раз, не может быть', + time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch, + status: 'sent', + attachments: [ + PhotoAttachment( + baseUrl: 'https://example.com/synthetic.jpg', + width: _photoWidth.toInt(), + height: 240, + ), + ], + payload: { + 'link': { + 'type': 'REPLY', + 'message': { + 'id': '9', + 'sender': _me, + 'text': + 'Эта функция, она для «спамеров - скамеров» и «мутных - анонимов»', + 'time': 0, + 'attaches': [], + }, + }, + }, +); + Future _pumpColumn( WidgetTester tester, List messages, { @@ -183,6 +215,24 @@ void main() { expect(groupGaps, dialogGaps); }); + testWidgets('a reply above a photo stays inside the photo width', ( + tester, + ) async { + await _pumpBubble(tester, _photoReply()); + + final quote = _rectOf( + tester, + find + .ancestor(of: find.text('Вы'), matching: find.byType(Container)) + .first, + ); + final caption = _rectOf(tester, find.text('Вот те раз, не может быть')); + + expect(quote.width, closeTo(_photoWidth - 16, 1)); + expect(quote.left, greaterThan(0)); + expect(quote.right, lessThanOrEqualTo(caption.left + _photoWidth)); + }); + testWidgets('a bubble without a header or reply still hugs its text', ( tester, ) async {