feat/fix: Все кнопки в экране профиля(кроме жалоб). Фикс ожирения бабла. Новые сообщения теперь не тянут вниз если ты отлистал

This commit is contained in:
Jganenokk
2026-08-04 22:31:00 +07:00
parent 6791a30150
commit e93b392f9a
20 changed files with 2006 additions and 161 deletions
+4
View File
@@ -333,6 +333,7 @@ class AccountModule {
ContactCache.clear(); ContactCache.clear();
TranscriptionCache.clear(); TranscriptionCache.clear();
ComplaintsModule.clear(); ComplaintsModule.clear();
ContactsModule.clearBlockedCache();
banners.clear(); banners.clear();
chats.resetForAccountSwitch(); chats.resetForAccountSwitch();
@@ -348,6 +349,7 @@ class AccountModule {
ContactCache.clear(); ContactCache.clear();
TranscriptionCache.clear(); TranscriptionCache.clear();
ComplaintsModule.clear(); ComplaintsModule.clear();
ContactsModule.clearBlockedCache();
banners.clear(); banners.clear();
chats.resetForAccountSwitch(); chats.resetForAccountSwitch();
@@ -380,6 +382,7 @@ class AccountModule {
ContactCache.clear(); ContactCache.clear();
TranscriptionCache.clear(); TranscriptionCache.clear();
ComplaintsModule.clear(); ComplaintsModule.clear();
ContactsModule.clearBlockedCache();
banners.clear(); banners.clear();
chats.resetForAccountSwitch(); chats.resetForAccountSwitch();
await ContactsModule.primeCacheFromDb(accountId); await ContactsModule.primeCacheFromDb(accountId);
@@ -431,6 +434,7 @@ class AccountModule {
ContactCache.clear(); ContactCache.clear();
TranscriptionCache.clear(); TranscriptionCache.clear();
ComplaintsModule.clear(); ComplaintsModule.clear();
ContactsModule.clearBlockedCache();
banners.clear(); banners.clear();
chats.resetForAccountSwitch(); chats.resetForAccountSwitch();
} }
+24 -11
View File
@@ -1,5 +1,6 @@
import '../api.dart'; import '../api.dart';
import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
class ComplaintReason { class ComplaintReason {
final int reasonId; final int reasonId;
@@ -9,6 +10,8 @@ class ComplaintReason {
} }
class ComplaintsModule { class ComplaintsModule {
static const int userTypeId = 6;
static Map<int, List<ComplaintReason>>? _cache; static Map<int, List<ComplaintReason>>? _cache;
static void clear() => _cache = null; static void clear() => _cache = null;
@@ -17,10 +20,15 @@ class ComplaintsModule {
final cached = _cache; final cached = _cache;
if (cached != null) return cached; if (cached != null) return cached;
final response = await api.sendRequest(Opcode.complainReasonsGet, { final Packet response;
'complainSync': 0, try {
}); response = await api.sendRequest(Opcode.complainReasonsGet, {
if (!response.isOk) return cached ?? const {}; 'complainSync': 0,
}, silent: true);
} catch (_) {
return const {};
}
if (!response.isOk) return const {};
final payload = response.payload; final payload = response.payload;
if (payload is! Map) return const {}; if (payload is! Map) return const {};
@@ -65,14 +73,19 @@ class ComplaintsModule {
required int reasonId, required int reasonId,
required int typeId, required int typeId,
required List<int> ids, required List<int> ids,
required int parentId, int? parentId,
}) async { }) async {
final response = await api.sendRequest(Opcode.complain, { final Packet response;
'reasonId': reasonId, try {
'typeId': typeId, response = await api.sendRequest(Opcode.complain, {
'ids': ids, 'reasonId': reasonId,
'parentId': parentId, 'typeId': typeId,
}); 'ids': ids,
'parentId': ?parentId,
}, silent: true);
} catch (_) {
return false;
}
if (!response.isOk) return false; if (!response.isOk) return false;
final payload = response.payload; final payload = response.payload;
return payload is Map && payload['success'] == true; return payload is Map && payload['success'] == true;
+64
View File
@@ -328,6 +328,70 @@ class ContactsModule {
return true; 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( static Future<void> syncFromLoginPayload(
Map<dynamic, dynamic> data, Map<dynamic, dynamic> data,
int accountId, int accountId,
+8 -2
View File
@@ -56,9 +56,12 @@ String messageFromErrorPayload(dynamic payload) {
if (msg == 'FAIL_WRONG_PASSWORD' || msg == 'FAIL_LOGIN_TOKEN') { if (msg == 'FAIL_WRONG_PASSWORD' || msg == 'FAIL_LOGIN_TOKEN') {
return 'Ваш токен был отклонён сервером, хм... Попробуйте войти ещё раз.'; return 'Ваш токен был отклонён сервером, хм... Попробуйте войти ещё раз.';
} }
for (final key in ['localizedMessage', 'message', 'title']) { for (final key in ['localizedMessage', 'title', 'message']) {
final v = payload[key]; 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 'Неизвестная ошибка'; return 'Неизвестная ошибка';
} }
@@ -67,6 +70,9 @@ String messageFromErrorPayload(dynamic payload) {
return s.isNotEmpty ? s : 'Неизвестная ошибка'; return s.isNotEmpty ? s : 'Неизвестная ошибка';
} }
bool _isRawServerTemplate(String text) =>
text.startsWith('Key: ') || text.startsWith('key: ');
bool isSessionExpiredPayload(dynamic payload) { bool isSessionExpiredPayload(dynamic payload) {
return payload is Map && return payload is Map &&
(payload['message'] == 'FAIL_LOGIN_TOKEN' || (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; final VoidCallback onSendTyping;
static const double _minPanelHeight = 120;
late final AnimationController anim; late final AnimationController anim;
final ValueNotifier<bool> showPanel = ValueNotifier(false); final ValueNotifier<bool> showPanel = ValueNotifier(false);
final ValueNotifier<bool> panelHold = ValueNotifier(true); final ValueNotifier<bool> panelHold = ValueNotifier(true);
double panelHeight = 300; final ValueNotifier<double> panelHeight = ValueNotifier(300);
double baseHeight = 300;
double maxHeight = 300;
Timer? _typingTimer; Timer? _typingTimer;
void hide() => showPanel.value = false; 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) { void _onAnimStatus(AnimationStatus status) {
final held = status != AnimationStatus.completed; final held = status != AnimationStatus.completed;
if (panelHold.value != held) panelHold.value = held; if (panelHold.value != held) panelHold.value = held;
@@ -61,5 +77,6 @@ class StickerPanelController {
anim.dispose(); anim.dispose();
showPanel.dispose(); showPanel.dispose();
panelHold.dispose(); panelHold.dispose();
panelHeight.dispose();
} }
} }
@@ -20,14 +20,21 @@ class StickerPanelView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final media = MediaQuery.of(context);
stickers.maxHeight = media.size.height - media.padding.top - 160;
return AnimatedBuilder( return AnimatedBuilder(
animation: stickers.anim, animation: stickers.anim,
child: LottieHoldScope( child: LottieHoldScope(
isHeld: stickers.panelHold, isHeld: stickers.panelHold,
child: StickerPanel( child: ValueListenableBuilder<double>(
height: stickers.panelHeight, valueListenable: stickers.panelHeight,
onStickerTap: onStickerTap, builder: (context, height, _) => StickerPanel(
onEmojiTap: onEmojiTap, height: height,
onStickerTap: onStickerTap,
onEmojiTap: onEmojiTap,
onResize: stickers.resizeBy,
),
), ),
), ),
builder: (context, child) { builder: (context, child) {
+384 -108
View File
@@ -4,9 +4,11 @@ import 'package:flutter/material.dart';
import 'package:komet/main.dart'; import 'package:komet/main.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../contacts/edit_contact_sheet.dart'; import '../contacts/edit_contact_sheet.dart';
import '../../../backend/modules/complaints.dart';
import '../../../backend/modules/contacts.dart'; import '../../../backend/modules/contacts.dart';
import '../../../backend/modules/messages.dart' show ContactCache; import '../../../backend/modules/messages.dart' show ContactCache;
import '../../../core/cache/info_cache.dart'; import '../../../core/cache/info_cache.dart';
import '../../../core/calls/call_controller.dart';
import '../../../core/config/app_show_extra_info.dart'; import '../../../core/config/app_show_extra_info.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../core/utils/format.dart'; import '../../../core/utils/format.dart';
@@ -17,6 +19,7 @@ import '../../widgets/animated_text_swap.dart';
import '../../widgets/avatar_history_screen.dart'; import '../../widgets/avatar_history_screen.dart';
import '../../widgets/chat_info/shared_content_tabs.dart'; import '../../widgets/chat_info/shared_content_tabs.dart';
import '../../widgets/connection_status.dart'; import '../../widgets/connection_status.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/formatted_message_text.dart'; import '../../widgets/formatted_message_text.dart';
import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/glossy_pill.dart'; import '../../widgets/glossy_pill.dart';
@@ -24,9 +27,11 @@ import '../../widgets/komet_avatar.dart';
import '../../widgets/profile_hero.dart'; import '../../widgets/profile_hero.dart';
import '../../widgets/swipe_route.dart'; import '../../widgets/swipe_route.dart';
import '../../../backend/modules/chats.dart'; import '../../../backend/modules/chats.dart';
import '../calls/call_screen.dart';
import '../contacts/open_contact_profile.dart'; import '../contacts/open_contact_profile.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
import 'group_invite_sheets.dart'; import 'group_invite_sheets.dart';
import 'profile_action_sheets.dart';
class _MemberInfo { class _MemberInfo {
final int id; final int id;
@@ -69,6 +74,7 @@ class ChatInfoScreen extends StatefulWidget {
final int? dialogPeerId; final int? dialogPeerId;
final ChatInfoTab? initialTab; final ChatInfoTab? initialTab;
final Object? heroTag; final Object? heroTag;
final bool openedFromChat;
final void Function(String messageId, int time)? onJumpToMessage; final void Function(String messageId, int time)? onJumpToMessage;
@@ -81,6 +87,7 @@ class ChatInfoScreen extends StatefulWidget {
this.dialogPeerId, this.dialogPeerId,
this.initialTab, this.initialTab,
this.heroTag, this.heroTag,
this.openedFromChat = false,
this.onJumpToMessage, this.onJumpToMessage,
}); });
@@ -123,6 +130,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
int _mediaChatId = 0; int _mediaChatId = 0;
String? _anchorMsgId; String? _anchorMsgId;
int _dontDisturbUntil = 0;
int _lastEventTime = 0;
bool _blocked = false;
bool _muteBusy = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -137,8 +149,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
super.dispose(); super.dispose();
} }
AppLocalizations get l10n => AppLocalizations.of(context)!;
List<String> get _tabs { List<String> get _tabs {
final l10n = AppLocalizations.of(context)!;
final showInfo = AppShowExtraInfo.current.value; final showInfo = AppShowExtraInfo.current.value;
switch (widget.chatType) { switch (widget.chatType) {
case 'DIALOG': case 'DIALOG':
@@ -193,6 +206,16 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
_chatInfo = info; _chatInfo = info;
_mediaChatId = (info?.raw['id'] as int?) ?? widget.chatId; _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']; final lastMessage = info?.raw['lastMessage'];
if (lastMessage is Map) { if (lastMessage is Map) {
_anchorMsgId = lastMessage['id']?.toString(); _anchorMsgId = lastMessage['id']?.toString();
@@ -235,6 +258,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
_presenceStatus = st; _presenceStatus = st;
_isOnline = st == 1; _isOnline = st == 1;
} }
if (!_isBot && _otherId != _myId) _loadBlockedState(_otherId!);
} }
} else if (info == null) { } else if (info == null) {
setState(() => _isLoading = false); 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() { String? _initialTabLabel() {
if (widget.initialTab != ChatInfoTab.media) return null; if (widget.initialTab != ChatInfoTab.media) return null;
final media = AppLocalizations.of(context)!.chatInfoTabMedia; final media = AppLocalizations.of(context)!.chatInfoTabMedia;
@@ -478,10 +509,12 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
if (_isLoading) if (_isLoading)
..._loadingBlocks(cs) ..._loadingBlocks(cs)
else ...[ else ...[
Text( SelectionArea(
_subtitle(), child: Text(
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), _subtitle(),
textAlign: TextAlign.center, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
textAlign: TextAlign.center,
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildActions(cs), _buildActions(cs),
@@ -520,34 +553,80 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
Widget _buildMoreButton(ColorScheme cs) { Widget _buildMoreButton(ColorScheme cs) {
final canEdit = widget.chatType == 'DIALOG' && _isContact; final entries = _moreMenuEntries();
if (!canEdit) { if (entries.isEmpty) {
return IconButton( return IconButton(
icon: Icon(Icons.more_vert, color: cs.onSurface), icon: Icon(Icons.more_vert, color: cs.onSurface),
onPressed: () {}, onPressed: null,
); );
} }
final l10n = AppLocalizations.of(context)!; return PopupMenuButton<VoidCallback>(
return PopupMenuButton<String>(
icon: Icon(Icons.more_vert, color: cs.onSurface), icon: Icon(Icons.more_vert, color: cs.onSurface),
onSelected: (v) { onSelected: (action) => action(),
if (v == 'edit') _openEdit();
},
itemBuilder: (_) => [ itemBuilder: (_) => [
PopupMenuItem<String>( for (final entry in entries)
value: 'edit', PopupMenuItem<VoidCallback>(
child: Row( value: entry.onTap,
children: [ child: Row(
Icon(Symbols.edit, size: 20, color: cs.onSurface), children: [
const SizedBox(width: 12), Icon(
Text(l10n.editContactMenu), 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 { Future<void> _openEdit() async {
final oneme = _nameEntry('ONEME'); final oneme = _nameEntry('ONEME');
final local = _localContact; final local = _localContact;
@@ -611,22 +690,24 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
children: [ children: [
const SizedBox(width: 36), const SizedBox(width: 36),
Flexible( Flexible(
child: ProfileHeroName( child: SelectionArea(
tag: widget.heroTag, child: ProfileHeroName(
text: custom, tag: widget.heroTag,
style: nameStyle, text: custom,
child: AnimatedTextSwap( style: nameStyle,
showAlternate: _showRealName, child: AnimatedTextSwap(
alignment: Alignment.center, showAlternate: _showRealName,
alternate: Text( alignment: Alignment.center,
real ?? custom, alternate: Text(
style: nameStyle, real ?? custom,
textAlign: TextAlign.center, style: nameStyle,
), textAlign: TextAlign.center,
child: Text( ),
custom, child: Text(
style: nameStyle, custom,
textAlign: TextAlign.center, style: nameStyle,
textAlign: TextAlign.center,
),
), ),
), ),
), ),
@@ -653,7 +734,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
String _subtitle() { String _subtitle() {
final l10n = AppLocalizations.of(context)!;
switch (widget.chatType) { switch (widget.chatType) {
case 'DIALOG': case 'DIALOG':
if (_peerDeleted) return l10n.chatInfoMemberDeleted; if (_peerDeleted) return l10n.chatInfoMemberDeleted;
@@ -676,62 +756,56 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
} }
Widget _buildActions(ColorScheme cs) { bool get _isMuted {
final l10n = AppLocalizations.of(context)!; if (_dontDisturbUntil == ChatsModule.muteOff) return false;
final List<({IconData icon, String label, VoidCallback? onTap})> btns; 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 (widget.chatType == 'DIALOG') {
if (_isBot) { btns = [
btns = [ chatBtn,
muteBtn,
if (!_isBot)
( (
icon: Icons.chat_bubble, icon: Icons.call,
label: l10n.contactProfileActionChat, label: l10n.contactProfileActionCall,
onTap: _openChat, 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') { } else if (widget.chatType == 'CHANNEL') {
btns = [ btns = [muteBtn, leaveBtn];
(
icon: Icons.notifications,
label: l10n.contactProfileActionSound,
onTap: null,
),
(icon: Icons.exit_to_app, label: l10n.chatInfoActionLeave, onTap: null),
];
} else { } else {
btns = [ btns = [chatBtn, muteBtn, leaveBtn];
(
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),
];
} }
return Padding( return Padding(
@@ -748,17 +822,226 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
void _openChat() { void _openChat() {
if (widget.openedFromChat) {
Navigator.of(context).pop();
return;
}
pushSwipeable( pushSwipeable(
context, context,
(_) => ChatScreen( (_) => ChatScreen(
chatId: widget.chatId, chatId: _mediaChatId,
name: widget.name, name: widget.name,
imageUrl: widget.imageUrl, 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( Widget _actionBtn(
ColorScheme cs, ColorScheme cs,
IconData icon, IconData icon,
@@ -789,7 +1072,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
Widget _buildPersistentInfo(ColorScheme cs) { Widget _buildPersistentInfo(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
final items = <Widget>[]; final items = <Widget>[];
if (widget.chatType == 'DIALOG') { if (widget.chatType == 'DIALOG') {
@@ -836,9 +1118,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
if (items.isEmpty) return const SizedBox.shrink(); if (items.isEmpty) return const SizedBox.shrink();
return Column( return SelectionArea(
crossAxisAlignment: CrossAxisAlignment.stretch, child: Column(
children: [...items, const SizedBox(height: 16)], crossAxisAlignment: CrossAxisAlignment.stretch,
children: [...items, const SizedBox(height: 16)],
),
); );
} }
@@ -880,7 +1164,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
Widget _linkCard(ColorScheme cs, String link) { Widget _linkCard(ColorScheme cs, String link) {
final l10n = AppLocalizations.of(context)!;
return GlossyPill( return GlossyPill(
color: cs.surfaceContainerHigh, color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
@@ -916,7 +1199,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
Widget _collapsibleDescCard(ColorScheme cs, String desc) { Widget _collapsibleDescCard(ColorScheme cs, String desc) {
final l10n = AppLocalizations.of(context)!;
const int collapsedLines = 3; const int collapsedLines = 3;
final isLong = desc.length > 120; final isLong = desc.length > 120;
@@ -1041,7 +1323,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
Widget _tabBody(ColorScheme cs) { Widget _tabBody(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
if (_selectedTab == 'Info') return _buildInfoTabContent(cs); if (_selectedTab == 'Info') return _buildInfoTabContent(cs);
if (_selectedTab == l10n.chatInfoTabMembers) { if (_selectedTab == l10n.chatInfoTabMembers) {
return _buildMembersTabContent(cs); return _buildMembersTabContent(cs);
@@ -1159,7 +1440,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
Widget _buildInfoTabContent(ColorScheme cs) { Widget _buildInfoTabContent(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
final items = <Widget>[]; final items = <Widget>[];
if (widget.chatType == 'CHAT') { if (widget.chatType == 'CHAT') {
@@ -1173,9 +1453,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
items.add(_buildInfoRowsCard(cs)); items.add(_buildInfoRowsCard(cs));
return Column( return SelectionArea(
crossAxisAlignment: CrossAxisAlignment.stretch, child: Column(
children: items, crossAxisAlignment: CrossAxisAlignment.stretch,
children: items,
),
); );
} }
@@ -1220,7 +1502,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
Widget _buildMembersTabContent(ColorScheme cs) { Widget _buildMembersTabContent(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surfaceContainerHigh, color: cs.surfaceContainerHigh,
@@ -1266,7 +1547,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
), ),
); );
} }
final l10n = AppLocalizations.of(context)!;
return InkWell( return InkWell(
onTap: () => _fetchMembersPage(), onTap: () => _fetchMembersPage(),
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
@@ -1316,7 +1596,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
); );
Widget _memberTile(ColorScheme cs, _MemberInfo member) { Widget _memberTile(ColorScheme cs, _MemberInfo member) {
final l10n = AppLocalizations.of(context)!;
final name = final name =
member.name ?? member.name ??
ContactCache.get(member.id) ?? ContactCache.get(member.id) ??
@@ -1436,7 +1715,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
Widget _buildAllInfoRows(ColorScheme cs) { Widget _buildAllInfoRows(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
final rows = <({String label, String value})>[]; final rows = <({String label, String value})>[];
final chat = _chatInfo?.raw; final chat = _chatInfo?.raw;
if (chat == null) { if (chat == null) {
@@ -1558,7 +1836,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
List<({String label, String value})> _buildExtraContactRows() { List<({String label, String value})> _buildExtraContactRows() {
final l10n = AppLocalizations.of(context)!;
final c = _contactData; final c = _contactData;
if (c == null) return const []; if (c == null) return const [];
final rows = <({String label, String value})>[]; final rows = <({String label, String value})>[];
@@ -1613,7 +1890,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
Widget? _trailingFor(String label, ColorScheme cs) { Widget? _trailingFor(String label, ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
if (label != l10n.chatInfoRowId) return null; if (label != l10n.chatInfoRowId) return null;
if (widget.chatType != 'DIALOG') return null; if (widget.chatType != 'DIALOG') return null;
if (_contactData == null) return null; if (_contactData == null) return null;
+175 -24
View File
@@ -109,6 +109,8 @@ import '../../widgets/liquid_glass.dart';
import 'scheduled_messages_screen.dart'; import 'scheduled_messages_screen.dart';
import 'chat_encryption_screen.dart'; import 'chat_encryption_screen.dart';
import 'chat_wallpaper_preview_screen.dart'; import 'chat_wallpaper_preview_screen.dart';
import 'chat/retain_offset_physics.dart';
import 'profile_action_sheets.dart';
class _DateSeparatorItem { class _DateSeparatorItem {
final DateTime date; final DateTime date;
@@ -546,6 +548,7 @@ class _ChatScreenState extends State<ChatScreen>
static const double _avgMessageHeight = 72.0; static const double _avgMessageHeight = 72.0;
static const double _historyPrefetchExtent = _avgMessageHeight * 8; static const double _historyPrefetchExtent = _avgMessageHeight * 8;
static const double _scrollDownRevealExtent = _avgMessageHeight * 30; static const double _scrollDownRevealExtent = _avgMessageHeight * 30;
static const double _scrollDownRevealFactor = 0.6;
static const double _scrollDownTeleportFactor = 2.0; static const double _scrollDownTeleportFactor = 2.0;
static const double _glossyHeaderHeight = 76.0; static const double _glossyHeaderHeight = 76.0;
static const double _glossySearchHeight = 58.0; static const double _glossySearchHeight = 58.0;
@@ -596,6 +599,12 @@ class _ChatScreenState extends State<ChatScreen>
late final AnimationController _scrollDownAnimController; late final AnimationController _scrollDownAnimController;
late final CurvedAnimation _scrollDownCurved; late final CurvedAnimation _scrollDownCurved;
bool _scrollDownVisible = false; 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; int _listEpoch = 0;
final List<({String id, double pixels, double alignment})> _returnStack = []; final List<({String id, double pixels, double alignment})> _returnStack = [];
bool _returningToAnchor = false; bool _returningToAnchor = false;
@@ -1151,6 +1160,7 @@ class _ChatScreenState extends State<ChatScreen>
chatType: widget.chatType, chatType: widget.chatType,
heroTag: _profileHeroTag, heroTag: _profileHeroTag,
initialTab: initialTab, initialTab: initialTab,
openedFromChat: true,
onJumpToMessage: (chatRoute == null || widget.embedded) onJumpToMessage: (chatRoute == null || widget.embedded)
? null ? null
: (messageId, time) { : (messageId, time) {
@@ -1613,6 +1623,52 @@ class _ChatScreenState extends State<ChatScreen>
_loadGroupSenderNames(); _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) { double? _messageOffsetInList(String messageId) {
final listBox = _listKey.currentContext?.findRenderObject(); final listBox = _listKey.currentContext?.findRenderObject();
final box = _keyForMessage(messageId).currentContext?.findRenderObject(); final box = _keyForMessage(messageId).currentContext?.findRenderObject();
@@ -1863,11 +1919,19 @@ class _ChatScreenState extends State<ChatScreen>
if (comment.senderId == _myId) return; if (comment.senderId == _myId) return;
if (_messages.any((m) => m.id == comment.id)) return; if (_messages.any((m) => m.id == comment.id)) return;
final nearBottom = _isNearListBottom(); final nearBottom = _isNearListBottom();
final anchorId = nearBottom ? null : _viewportAnchorId();
final anchorDy = anchorId == null ? null : _messageOffsetInList(anchorId);
if (!nearBottom) _retainOffsetForNextLayout();
_messages.add(comment); _messages.add(comment);
_syncReactionNotifiersFromMessages(); _syncReactionNotifiersFromMessages();
_bumpMessages(); _bumpMessages();
unawaited(_resolveCommentNames([comment])); unawaited(_resolveCommentNames([comment]));
if (nearBottom) _scrollToBottom(); if (nearBottom) {
_scrollToBottom();
} else {
_noteMissedMessage();
unawaited(_holdScrollAfterAppend(anchorId, anchorDy));
}
} }
bool _isNearListBottom() { bool _isNearListBottom() {
@@ -1959,6 +2023,7 @@ class _ChatScreenState extends State<ChatScreen>
_floatingDate.dispose(); _floatingDate.dispose();
_scrollDownCurved.dispose(); _scrollDownCurved.dispose();
_scrollDownAnimController.dispose(); _scrollDownAnimController.dispose();
_newMessageCount.dispose();
_hasText.dispose(); _hasText.dispose();
_scheduledCount.dispose(); _scheduledCount.dispose();
_showAttachmentPanel.removeListener(_onAttachPanelToggle); _showAttachmentPanel.removeListener(_onAttachPanelToggle);
@@ -2885,6 +2950,11 @@ class _ChatScreenState extends State<ChatScreen>
if (message.senderId == _myId) return; if (message.senderId == _myId) return;
if (_messages.any((m) => m.id == message.id)) return; if (_messages.any((m) => m.id == message.id)) return;
final nearBottom = _isNearBottom(); final nearBottom = _isNearBottom();
final anchorId = nearBottom ? null : _viewportAnchorId();
final anchorDy = anchorId == null
? null
: _messageOffsetInList(anchorId);
if (!nearBottom) _retainOffsetForNextLayout();
_lastSentId = message.id; _lastSentId = message.id;
_messages.add(message); _messages.add(message);
_bumpMessages(); _bumpMessages();
@@ -2894,6 +2964,8 @@ class _ChatScreenState extends State<ChatScreen>
_scrollToBottom(); _scrollToBottom();
_scheduleReadMarker(); _scheduleReadMarker();
} else { } else {
_noteMissedMessage();
unawaited(_holdScrollAfterAppend(anchorId, anchorDy));
_reapplyPinIfNeeded(); _reapplyPinIfNeeded();
} }
_prank.checkTrigger(message); _prank.checkTrigger(message);
@@ -3335,20 +3407,27 @@ class _ChatScreenState extends State<ChatScreen>
} }
Future<void> _clearHistory() async { 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, context,
title: 'Очистить историю', title: 'Очистить историю',
message: message:
'Все сообщения в этом чате будут удалены без возможности ' 'Все сообщения в этом чате будут удалены без возможности '
'восстановления.', 'восстановления.',
confirmLabel: 'Очистить', confirmLabel: 'Очистить',
cancelLabel: 'Отмена',
destructive: true, destructive: true,
checkboxLabel: canClearForAll ? 'Для всех' : null,
); );
if (!mounted || !confirmed) return; if (!mounted || !choice.confirmed) return;
final err = await chats.clearHistory( final err = await chats.clearHistory(
api, api,
chatId: widget.chatId, chatId: widget.chatId,
lastEventTime: chat?.lastEventTime ?? 0, lastEventTime: current?.lastEventTime ?? 0,
forAll: canClearForAll && choice.checked,
); );
if (!mounted) return; if (!mounted) return;
if (err != null) { if (err != null) {
@@ -4191,6 +4270,7 @@ class _ChatScreenState extends State<ChatScreen>
void _scrollToBottom() { void _scrollToBottom() {
_returnStack.clear(); _returnStack.clear();
_newMessageCount.value = 0;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollController.hasClients) return; if (!_scrollController.hasClients) return;
final pos = _scrollController.position; final pos = _scrollController.position;
@@ -4221,15 +4301,30 @@ class _ChatScreenState extends State<ChatScreen>
} }
void _updateScrollDownVisible() { void _updateScrollDownVisible() {
if (!_scrollController.hasClients) return; if (!_scrollController.hasClients) {
_setScrollDownVisible(_newMessageCount.value > 0);
return;
}
final pos = _scrollController.position; final pos = _scrollController.position;
final atBottom = _isNearBottom();
if (_returnStack.isNotEmpty && if (_returnStack.isNotEmpty &&
_isNearBottom() && atBottom &&
pos.userScrollDirection != ScrollDirection.idle) { pos.userScrollDirection != ScrollDirection.idle) {
_returnStack.clear(); _returnStack.clear();
} }
final show = if (atBottom && _newMessageCount.value > 0) _clearNewMessageCountSoon();
pos.pixels >= _scrollDownRevealExtent || _returnStack.isNotEmpty; 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; if (show == _scrollDownVisible) return;
_scrollDownVisible = show; _scrollDownVisible = show;
if (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) { void _pushReturnAnchor(String messageId) {
if (!_scrollController.hasClients) return; if (!_scrollController.hasClients) return;
final listBox = _listKey.currentContext?.findRenderObject(); final listBox = _listKey.currentContext?.findRenderObject();
@@ -5332,6 +5443,7 @@ class _ChatScreenState extends State<ChatScreen>
return CustomScrollView( return CustomScrollView(
controller: _scrollController, controller: _scrollController,
reverse: true, reverse: true,
physics: _listPhysics,
cacheExtent: cacheExtent, cacheExtent: cacheExtent,
slivers: [ slivers: [
SliverPadding( SliverPadding(
@@ -5627,27 +5739,67 @@ class _ChatScreenState extends State<ChatScreen>
child: SizedBox( child: SizedBox(
width: 46, width: 46,
height: 46, height: 46,
child: GlossyPill( child: Stack(
color: frosted || _liquidChrome ? AppFrost.pillTint(cs) : null, clipBehavior: Clip.none,
blurSigma: frosted && !_liquidChrome ? AppFrost.sigma : null, children: [
liquid: _liquidChrome, Positioned.fill(
backdropKey: _pillBackdrop, child: GlossyPill(
elevated: true, color: frosted || _liquidChrome
onTap: _onScrollDownTap, ? AppFrost.pillTint(cs)
child: Center( : null,
child: Icon( blurSigma: frosted && !_liquidChrome ? AppFrost.sigma : null,
Symbols.keyboard_arrow_down, liquid: _liquidChrome,
color: cs.onSurface, backdropKey: _pillBackdrop,
weight: 500, elevated: true,
size: 26, 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}) { Uint8List _buildWave(List<double> amps, {int bars = 80}) {
final out = Uint8List(bars); final out = Uint8List(bars);
if (amps.isEmpty) return out; if (amps.isEmpty) return out;
@@ -6303,13 +6455,12 @@ class _ChatScreenState extends State<ChatScreen>
} }
final keyboard = MediaQuery.viewInsetsOf(context).bottom; final keyboard = MediaQuery.viewInsetsOf(context).bottom;
_keyboardBeforeStickers = keyboard > 120 || _messageFocusNode.hasFocus; _keyboardBeforeStickers = keyboard > 120 || _messageFocusNode.hasFocus;
if (keyboard > 120) _stickers.panelHeight = keyboard; if (keyboard > 120) _stickers.setBaseHeight(keyboard);
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
_stickers.showPanel.value = true; _stickers.showPanel.value = true;
} }
Future<void> _sendSticker(StickerItem sticker) async { Future<void> _sendSticker(StickerItem sticker) async {
_stickers.hide();
await _sendAttachMessage([ await _sendAttachMessage([
StickerAttachment( StickerAttachment(
stickerId: sticker.id.toString(), 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, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.black.withValues(alpha: 0.28), barrierColor: Colors.black.withValues(alpha: 0.28),
transitionDuration: const Duration(milliseconds: 260), transitionDuration: const Duration(milliseconds: 260),
pageBuilder: (_, _, _) => const SizedBox.shrink(), pageBuilder: (_, _, _) => builder(context),
transitionBuilder: (_, anim, _, _) { transitionBuilder: (_, anim, _, child) {
final t = Curves.easeOutCubic.transform(anim.value); final t = Curves.easeOutCubic.transform(anim.value);
return BackdropFilter( return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 14 * t, sigmaY: 14 * t), filter: ImageFilter.blur(sigmaX: 14 * t, sigmaY: 14 * t),
child: Opacity( child: Opacity(
opacity: anim.value, opacity: anim.value,
child: Transform.scale( child: Transform.scale(scale: 0.94 + 0.06 * t, child: child),
scale: 0.94 + 0.06 * t,
child: builder(context),
),
), ),
); );
}, },
+93 -5
View File
@@ -73,6 +73,85 @@ class _RenderZeroIntrinsicWidth extends RenderProxyBox {
double computeMaxIntrinsicWidth(double height) => 0; 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 /// 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 /// [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 /// 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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
?senderHeader, ?senderHeader,
if (reply != null) ...[ if (reply == null)
_buildReplyQuote(context, cs, textColor, reply), contentWithReactions
const SizedBox(height: 4), else
], _HeaderAboveMatchWidth(
contentWithReactions, 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),
),
),
], ],
); );
+24
View File
@@ -50,12 +50,14 @@ class StickerPanel extends StatefulWidget {
final double height; final double height;
final void Function(StickerItem sticker) onStickerTap; final void Function(StickerItem sticker) onStickerTap;
final void Function(Animoji animoji)? onEmojiTap; final void Function(Animoji animoji)? onEmojiTap;
final void Function(double delta)? onResize;
const StickerPanel({ const StickerPanel({
super.key, super.key,
required this.height, required this.height,
required this.onStickerTap, required this.onStickerTap,
this.onEmojiTap, this.onEmojiTap,
this.onResize,
}); });
@override @override
@@ -68,6 +70,7 @@ class _StickerPanelState extends State<StickerPanel>
static const double _headerHeight = 34; static const double _headerHeight = 34;
static const double _searchFieldHeight = 50; static const double _searchFieldHeight = 50;
static const double _toggleBarHeight = 48; static const double _toggleBarHeight = 48;
static const double _resizeHandleHeight = 16;
static const int _modeEmoji = 0; static const int _modeEmoji = 0;
static const int _modeStickers = 1; static const int _modeStickers = 1;
static const String _modePrefKey = 'komet_panel_mode'; static const String _modePrefKey = 'komet_panel_mode';
@@ -257,6 +260,7 @@ class _StickerPanelState extends State<StickerPanel>
top: false, top: false,
child: Column( child: Column(
children: [ children: [
if (widget.onResize != null) _buildResizeHandle(cs),
Expanded( Expanded(
child: _mode == _modeEmoji && widget.onEmojiTap != null child: _mode == _modeEmoji && widget.onEmojiTap != null
? EmojiPanel(onEmojiTap: widget.onEmojiTap!) ? 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) { Widget _buildStickerBody(ColorScheme cs) {
if (_loading) return Center(child: SmallSpinner()); if (_loading) return Center(child: SmallSpinner());
if (_error != null || _sections.isEmpty) { if (_error != null || _sections.isEmpty) {
+53
View File
@@ -601,6 +601,59 @@
"sharedCopyLink": "Copy link", "sharedCopyLink": "Copy link",
"sharedLinkCopied": "Link copied", "sharedLinkCopied": "Link copied",
"chatInfoActionLeave": "Leave", "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", "chatInfoBio": "About",
"chatInfoInviteLink": "Invite link", "chatInfoInviteLink": "Invite link",
"chatInfoCollapse": "Collapse", "chatInfoCollapse": "Collapse",
+234
View File
@@ -2702,6 +2702,240 @@ abstract class AppLocalizations {
/// **'Leave'** /// **'Leave'**
String get chatInfoActionLeave; 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. /// No description provided for @chatInfoBio.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+125
View File
@@ -1393,6 +1393,131 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get chatInfoActionLeave => 'Leave'; 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 @override
String get chatInfoBio => 'About'; String get chatInfoBio => 'About';
+125
View File
@@ -1401,6 +1401,131 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get chatInfoActionLeave => 'Покинуть'; 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 @override
String get chatInfoBio => 'О себе'; String get chatInfoBio => 'О себе';
+39
View File
@@ -457,6 +457,45 @@
"sharedCopyLink": "Копировать ссылку", "sharedCopyLink": "Копировать ссылку",
"sharedLinkCopied": "Ссылка скопирована", "sharedLinkCopied": "Ссылка скопирована",
"chatInfoActionLeave": "Покинуть", "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": "О себе", "chatInfoBio": "О себе",
"chatInfoInviteLink": "Ссылка-приглашение", "chatInfoInviteLink": "Ссылка-приглашение",
"chatInfoCollapse": "Свернуть", "chatInfoCollapse": "Свернуть",
+141
View File
@@ -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<String> items = [for (var i = 0; i < 40; i++) 'm$i'];
late final Map<String, GlobalKey> keys = {
for (final id in items) id: GlobalKey(),
'newest': GlobalKey(),
};
Future<void> 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);
});
}
+50
View File
@@ -3,9 +3,11 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:komet/backend/modules/messages.dart'; import 'package:komet/backend/modules/messages.dart';
import 'package:komet/frontend/widgets/message_bubble.dart'; import 'package:komet/frontend/widgets/message_bubble.dart';
import 'package:komet/l10n/app_localizations.dart'; import 'package:komet/l10n/app_localizations.dart';
import 'package:komet/models/attachment.dart';
const int _me = 1; const int _me = 1;
const int _peer = 7; const int _peer = 7;
const double _photoWidth = 180;
CachedMessage _message({ CachedMessage _message({
required String text, required String text,
@@ -35,6 +37,36 @@ CachedMessage _message({
: null, : 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<void> _pumpColumn( Future<void> _pumpColumn(
WidgetTester tester, WidgetTester tester,
List<CachedMessage> messages, { List<CachedMessage> messages, {
@@ -183,6 +215,24 @@ void main() {
expect(groupGaps, dialogGaps); 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', ( testWidgets('a bubble without a header or reply still hugs its text', (
tester, tester,
) async { ) async {