feat/fix: Все кнопки в экране профиля(кроме жалоб). Фикс ожирения бабла. Новые сообщения теперь не тянут вниз если ты отлистал
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 => 'О себе';
|
||||||
|
|
||||||
|
|||||||
@@ -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": "Свернуть",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user