легонько набурмалдил, остальнось сделать FKM и фронт уведомлений

This commit is contained in:
Jganenok
2026-05-28 16:44:59 +07:00
parent 7ce4459304
commit 0947863697
13 changed files with 1186 additions and 204 deletions
@@ -3,9 +3,8 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/messages.dart' show ContactCache;
import '../../../core/protocol/opcode_map.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/storage/app_database.dart';
import '../../../main.dart' as main;
class _MemberInfo {
final int id;
@@ -96,21 +95,13 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
final profile = await AppDatabase.loadActiveProfile();
_myId = profile?.id ?? 0;
final packet = await main.api.sendRequest(
Opcode.chatInfo,
{'chatIds': [widget.chatId]},
);
if (!packet.isOk || !mounted) {
if (mounted) setState(() => _isLoading = false);
final info = await ChatInfoFetch.get(widget.chatId);
if (!mounted) return;
if (info == null) {
setState(() => _isLoading = false);
return;
}
final chats = (packet.payload as Map?)?['chats'] as List?;
if (chats == null || chats.isEmpty) {
if (mounted) setState(() => _isLoading = false);
return;
}
_chatData = Map<String, dynamic>.from(chats.first as Map);
_chatData = info;
if (widget.chatType == 'DIALOG') {
final parts = _chatData!['participants'] as Map? ?? {};
@@ -123,32 +114,19 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
}
if (_otherId != null) {
final cp = await main.api.sendRequest(
Opcode.contactInfo,
{'contactIds': [_otherId]},
);
if (cp.isOk) {
final contacts = (cp.payload as Map?)?['contacts'] as List?;
if (contacts != null && contacts.isNotEmpty) {
_contactData = Map<String, dynamic>.from(contacts.first as Map);
final opts = _contactData!['options'];
_isBot = (opts is List) && opts.contains('BOT');
}
final contact = await ContactInfoFetch.get(_otherId!);
if (contact != null) {
_contactData = contact;
final opts = _contactData!['options'];
_isBot = (opts is List) && opts.contains('BOT');
}
final pp = await main.api.sendRequest(
Opcode.contactPresence,
{'contactIds': [_otherId]},
);
if (pp.isOk) {
final presence = (pp.payload as Map?)?['presence'] as Map?;
final p = presence?[_otherId.toString()] ?? presence?[_otherId];
if (p is Map) {
_seenTime = p['seen'] as int?;
final st = (p['status'] as int?) ?? 0;
_presenceStatus = st;
_isOnline = st == 1;
}
final presence = await PresenceFetch.get(_otherId!);
if (presence != null) {
_seenTime = presence['seen'] as int?;
final st = (presence['status'] as int?) ?? 0;
_presenceStatus = st;
_isOnline = st == 1;
}
}
} else if (widget.chatType == 'CHAT') {
@@ -162,25 +140,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
if (id != null) memberIds.add(id);
}
final Map<int, Map> presenceMap = {};
Map<int, Map<String, dynamic>> presenceMap = {};
if (memberIds.isNotEmpty) {
final pp = await main.api.sendRequest(
Opcode.contactPresence,
{'contactIds': memberIds},
);
if (pp.isOk) {
final presence = (pp.payload as Map?)?['presence'] as Map?;
if (presence != null) {
for (final e in presence.entries) {
final id = e.key is int
? e.key as int
: int.tryParse(e.key.toString());
if (id != null && e.value is Map) {
presenceMap[id] = e.value as Map;
}
}
}
}
presenceMap = await PresenceFetch.getMany(memberIds);
}
_onlineCount = 0;
@@ -1367,10 +1367,15 @@ class _ChatListScreenState extends State<ChatListScreen>
// chat.isOfficial covers contacts from the login payload.
final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial;
final isPlaceholder =
chat.lastMsgText == ChatsModule.lastMsgPlaceholder;
final previewText = isPlaceholder
? 'зайдите в чат для подгрузки'
: (chat.lastMsgTextOneLine ?? '');
return _buildChatItem(
chat.id.toString(),
name ?? "Пользователь",
chat.lastMsgTextOneLine ?? '',
previewText,
_formatTime(chat.lastMsgTime),
avatar ?? "",
isOnline: chat.isOnline,
@@ -1379,20 +1384,25 @@ class _ChatListScreenState extends State<ChatListScreen>
isVerified: isVerified,
isPinned: isPinned,
chatType: "DIALOG",
messageItalic: isPlaceholder,
);
} else {
final name = chat.lastMsgSenderId != null
final isPlaceholder =
chat.lastMsgText == ChatsModule.lastMsgPlaceholder;
final sender = chat.lastMsgSenderId != null
? ContactCache.get(chat.lastMsgSenderId!)
: null;
String fullMsg = "";
if (name?.isNotEmpty == true && chat.id != 0) {
fullMsg += "$name: ";
}
if (chat.lastMsgText?.isNotEmpty == true) {
fullMsg += chat.lastMsgText ?? "";
if (isPlaceholder) {
fullMsg = 'зайдите в чат для подгрузки';
} else {
if (sender?.isNotEmpty == true && chat.id != 0) {
fullMsg += "$sender: ";
}
if (chat.lastMsgText?.isNotEmpty == true) {
fullMsg += chat.lastMsgText ?? "";
}
}
return _buildChatItem(
@@ -1409,6 +1419,7 @@ class _ChatListScreenState extends State<ChatListScreen>
isVerified: chat.isOfficial,
isPinned: isPinned,
chatType: chat.type,
messageItalic: isPlaceholder,
);
}
}, childCount: totalItems),
@@ -2049,6 +2060,7 @@ class _ChatListScreenState extends State<ChatListScreen>
bool isVerified = false,
bool isPinned = false,
String chatType = "CHAT",
bool messageItalic = false,
}) {
final cs = Theme.of(context).colorScheme;
final isSelected = _selectedChats.contains(id);
@@ -2230,6 +2242,9 @@ class _ChatListScreenState extends State<ChatListScreen>
fontWeight: isTyping
? FontWeight.w500
: FontWeight.w400,
fontStyle: messageItalic
? FontStyle.italic
: FontStyle.normal,
height: 1.2,
),
maxLines: 1,
+142 -91
View File
@@ -13,11 +13,11 @@ import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart';
import '../../../backend/api.dart';
import '../../../backend/modules/messages.dart';
import '../../../core/protocol/opcode_map.dart';
import '../../../core/protocol/packet.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/utils/haptics.dart';
import '../../../core/config/app_cache_extent.dart';
import '../../../core/config/app_message_actions_style.dart';
@@ -89,6 +89,27 @@ class _ChatScreenState extends State<ChatScreen>
final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus());
StreamSubscription<UploadEvent>? _uploadSub;
StreamSubscription<Packet>? _pushSub;
StreamSubscription<MessageEvent>? _messageEventSub;
final Map<String, ValueNotifier<Map<String, dynamic>?>> _reactionNotifiers = {};
ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) {
final existing = _reactionNotifiers[m.id];
if (existing != null) return existing;
final info = m.payload?['reactionInfo'];
final notifier = ValueNotifier<Map<String, dynamic>?>(
info is Map ? Map<String, dynamic>.from(info) : null,
);
_reactionNotifiers[m.id] = notifier;
return notifier;
}
void _pruneReactionNotifiers() {
final liveIds = _messages.map((m) => m.id).toSet();
final dead = _reactionNotifiers.keys.where((id) => !liveIds.contains(id)).toList();
for (final id in dead) {
_reactionNotifiers.remove(id)?.dispose();
}
}
final Set<int> _typingUserIds = {};
final Map<int, Timer> _typingTimers = {};
int _otherStatus = 0;
@@ -130,10 +151,12 @@ class _ChatScreenState extends State<ChatScreen>
_showAttachmentPanel.addListener(_onAttachPanelToggle);
_pushSub = api.pushStream
.where((p) =>
p.opcode == Opcode.notifMessage ||
p.opcode == Opcode.notifMark ||
p.opcode == Opcode.notifTyping)
.listen(_onIncomingPush);
_messageEventSub = ChatsModule.messageEvents
.where((e) => e.chatId == widget.chatId)
.listen(_onMessageEvent);
_floatingDateAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
@@ -145,9 +168,42 @@ class _ChatScreenState extends State<ChatScreen>
reverseCurve: Curves.easeIn,
);
unawaited(_fastPreloadCache());
WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered);
}
Future<void> _fastPreloadCache() async {
final p = await AppDatabase.loadActiveProfile();
if (!mounted) return;
_myId = p?.id ?? 0;
ChatsModule.getChat(_myId, widget.chatId).then((value) {
if (mounted && value.isNotEmpty) {
setState(() {
chat = value.first;
});
_recomputeHeaderStatus();
}
}).catchError((_) {});
final firstRows = await AppDatabase.loadMessages(
_myId,
widget.chatId,
limit: 20,
);
if (!mounted) return;
if (firstRows.isNotEmpty) {
final first = firstRows.reversed
.map((r) => CachedMessage.fromDbRow(r))
.toList();
setState(() {
_messages = first;
_isLoading = false;
_onLoadingFinished();
});
}
}
void _onFirstFrameRendered(Duration _) {
if (!mounted) return;
if (widget.embedded) {
@@ -192,37 +248,15 @@ class _ChatScreenState extends State<ChatScreen>
}
Future<void> _loadHistory() async {
final activeProfile = await AppDatabase.loadActiveProfile();
_myId = activeProfile?.id ?? 0;
ChatsModule.getChat(_myId, widget.chatId).then((value) {
if (mounted && value.isNotEmpty) {
setState(() { chat = value.first; });
_recomputeHeaderStatus();
}
}).catchError((_) {});
if (_myId == 0) {
final activeProfile = await AppDatabase.loadActiveProfile();
if (!mounted) return;
_myId = activeProfile?.id ?? 0;
}
if (widget.chatType == 'DIALOG') {
unawaited(_loadOtherPresence());
}
final firstRows = await AppDatabase.loadMessages(
_myId,
widget.chatId,
limit: 20,
);
if (mounted && firstRows.isNotEmpty) {
final first = firstRows.reversed
.map((r) => CachedMessage.fromDbRow(r))
.toList();
setState(() {
_messages = first;
if (api.state == SessionState.online) {
_isLoading = false;
_onLoadingFinished();
}
});
}
unawaited(_loadRemainingHistory());
await _loadRemainingHistory();
}
Future<void> _loadRemainingHistory() async {
@@ -235,8 +269,20 @@ class _ChatScreenState extends State<ChatScreen>
_applyMergedMessages(fullRows);
}
if (!ChatsModule.isChatDirty(widget.chatId) && fullRows.isNotEmpty) {
if (mounted) {
setState(() {
_isLoading = false;
_onLoadingFinished();
});
}
_loadForwardedSenderNames();
return;
}
try {
await messagesModule.fetchHistory(_myId, widget.chatId);
ChatsModule.markChatClean(widget.chatId);
final updatedRows = await AppDatabase.loadMessages(
_myId,
widget.chatId,
@@ -245,6 +291,7 @@ class _ChatScreenState extends State<ChatScreen>
if (mounted) {
_applyMergedMessages(updatedRows, markLoaded: true);
}
unawaited(ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId));
_loadForwardedSenderNames();
} catch (e) {
debugPrint('Error fetching history: $e');
@@ -280,6 +327,33 @@ class _ChatScreenState extends State<ChatScreen>
_onLoadingFinished();
}
});
if (changed) {
_syncReactionNotifiersFromMessages();
_pruneReactionNotifiers();
}
}
void _syncReactionNotifiersFromMessages() {
for (final m in _messages) {
final info = m.payload?['reactionInfo'];
final value = info is Map ? Map<String, dynamic>.from(info) : null;
final existing = _reactionNotifiers[m.id];
if (existing == null) {
_reactionNotifiers[m.id] = ValueNotifier(value);
} else if (!_reactionsEqual(existing.value, value)) {
existing.value = value;
}
}
}
bool _reactionsEqual(Map<String, dynamic>? a, Map<String, dynamic>? b) {
if (identical(a, b)) return true;
if (a == null || b == null) return false;
if (a.length != b.length) return false;
for (final k in a.keys) {
if (a[k].toString() != b[k].toString()) return false;
}
return true;
}
bool _sameMessage(CachedMessage a, CachedMessage b) {
@@ -311,6 +385,11 @@ class _ChatScreenState extends State<ChatScreen>
_showAttachmentPanel.dispose();
_uploadSub?.cancel();
_pushSub?.cancel();
_messageEventSub?.cancel();
for (final n in _reactionNotifiers.values) {
n.dispose();
}
_reactionNotifiers.clear();
for (final t in _typingTimers.values) {
t.cancel();
}
@@ -358,8 +437,6 @@ class _ChatScreenState extends State<ChatScreen>
void _onIncomingPush(Packet packet) {
if (!mounted) return;
switch (packet.opcode) {
case Opcode.notifMessage:
_onIncomingMessage(packet);
case Opcode.notifMark:
_onMessageRead(packet);
case Opcode.notifTyping:
@@ -367,23 +444,43 @@ class _ChatScreenState extends State<ChatScreen>
}
}
void _onMessageEvent(MessageEvent event) {
if (!mounted) return;
switch (event) {
case MessageAddedEvent(:final message):
if (message.senderId == _myId) return;
if (_messages.any((m) => m.id == message.id)) return;
setState(() {
_lastSentId = message.id;
_messages.add(message);
});
_clearTyping(message.senderId);
Haptics.tap();
_scrollToBottom();
case MessageEditedEvent(:final message):
final idx = _messages.indexWhere((m) => m.id == message.id);
if (idx == -1) return;
setState(() => _messages[idx] = message);
case MessageRemovedEvent(:final messageId):
final idx = _messages.indexWhere((m) => m.id == messageId);
if (idx == -1) return;
setState(() => _messages.removeAt(idx));
_reactionNotifiers.remove(messageId)?.dispose();
case MessageReactionsChangedEvent(:final messageId, :final reactionInfo):
_reactionNotifiers[messageId]?.value = reactionInfo;
}
}
Future<void> _loadOtherPresence() async {
if (_myId == 0) return;
final otherId = widget.chatId ^ _myId;
if (otherId <= 0) return;
try {
final p = await api.sendRequest(
Opcode.contactPresence,
{'contactIds': [otherId]},
);
if (!mounted) return;
final presence = (p.payload as Map?)?['presence'] as Map?;
final entry = presence?[otherId.toString()] ?? presence?[otherId];
if (entry is Map) {
_otherStatus = (entry['status'] as int?) ?? 0;
_otherSeenTime = entry['seen'] as int?;
_recomputeHeaderStatus();
}
final entry = await PresenceFetch.get(otherId);
if (!mounted || entry == null) return;
_otherStatus = (entry['status'] as int?) ?? 0;
_otherSeenTime = entry['seen'] as int?;
_recomputeHeaderStatus();
} catch (_) {}
}
@@ -463,53 +560,6 @@ class _ChatScreenState extends State<ChatScreen>
});
}
void _onIncomingMessage(Packet packet) {
if (!mounted) return;
final payload = packet.payload;
if (payload is! Map) return;
final chatId = payload['chatId'];
if (chatId != widget.chatId) return;
final msg = payload['message'];
if (msg is! Map) return;
final senderId = msg['sender'];
if (senderId is! int) return;
if (senderId == _myId) return;
final msgId = msg['id']?.toString();
if (msgId == null || msgId.isEmpty) return;
if (_messages.any((m) => m.id == msgId)) return;
List<MessageAttachment>? attachments;
final attaches = msg['attaches'];
if (attaches is List && attaches.isNotEmpty) {
attachments = attaches
.whereType<Map>()
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
.toList();
}
final cached = CachedMessage(
id: msgId,
accountId: _myId,
chatId: widget.chatId,
senderId: senderId,
text: msg['text'] as String?,
time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch,
status: 'sent',
payload: Map<String, dynamic>.from(msg),
attachments: attachments,
);
setState(() {
_lastSentId = msgId;
_messages.add(cached);
});
_clearTyping(senderId);
Haptics.tap();
_scrollToBottom();
}
Future<void> _sendMessage() async {
final text = _messageController.text.trim();
if (text.isEmpty || _myId == 0) return;
@@ -1006,6 +1056,7 @@ class _ChatScreenState extends State<ChatScreen>
nextMessage: nextMessage,
chatType: chat?.type ?? 'CHAT',
overrideStatus: _effectiveStatus(message),
reactionsListenable: _reactionNotifierFor(message),
);
final pressable = _LongPressBubble(
@@ -2,10 +2,9 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/protocol/opcode_map.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/token_storage.dart';
import '../../../main.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/swipe_route.dart';
import '../chats/chat_screen.dart';
@@ -41,25 +40,18 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
Future<void> _load() async {
try {
final results = await Future.wait([
api.sendRequest(Opcode.contactInfo, {'contactIds': [widget.contactId]}),
api.sendRequest(Opcode.contactPresence, {'contactIds': [widget.contactId]}),
ContactInfoFetch.get(widget.contactId),
PresenceFetch.get(widget.contactId),
]);
if (!mounted) return;
final infoPacket = results[0];
if (infoPacket.isOk) {
final contacts = (infoPacket.payload as Map?)?['contacts'] as List?;
if (contacts != null && contacts.isNotEmpty) {
_contact = Map<String, dynamic>.from(contacts.first as Map);
}
final contact = results[0];
if (contact != null) {
_contact = contact;
}
final presencePacket = results[1];
if (presencePacket.isOk) {
final presence = (presencePacket.payload as Map?)?['presence'] as Map?;
final p = presence?[widget.contactId.toString()] ?? presence?[widget.contactId];
if (p is Map) {
_seenTime = p['seen'] as int?;
_presenceStatus = (p['status'] as int?) ?? 0;
}
final presence = results[1];
if (presence != null) {
_seenTime = presence['seen'] as int?;
_presenceStatus = (presence['status'] as int?) ?? 0;
}
} catch (e) {
if (mounted) showCustomNotification(context, 'Ошибка: $e');
@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'package:material_symbols_icons/symbols.dart';
class NotificationsScreen extends StatefulWidget {
const NotificationsScreen({super.key});
@override
State<NotificationsScreen> createState() => _NotificationsScreenState();
}
class _NotificationsScreenState extends State<NotificationsScreen> {
bool _fkmEnabled = false;
bool _personalChatsEnabled = true;
bool _groupsEnabled = true;
bool _channelsEnabled = true;
String _selectedSound = 'По умолчанию';
static const List<String> _sounds = [
'По умолчанию',
'Колокольчик',
'Звон',
'Капля',
'Беззвучно',
];
Future<void> _pickSound() async {
final cs = Theme.of(context).colorScheme;
final picked = await showModalBottomSheet<String>(
context: context,
backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Row(
children: [
Text(
'Звук уведомления',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
],
),
),
for (final s in _sounds)
ListTile(
onTap: () => Navigator.of(context).pop(s),
leading: Icon(
s == _selectedSound
? Symbols.radio_button_checked
: Symbols.radio_button_unchecked,
color: s == _selectedSound
? cs.primary
: cs.onSurfaceVariant,
),
title: Text(
s,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
),
),
),
],
),
),
);
},
);
if (picked != null && picked != _selectedSound) {
setState(() => _selectedSound = picked);
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBarM3E(
titleText: 'Уведомления',
backgroundColor: cs.surface,
),
body: SafeArea(
top: false,
child: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
children: [
_sectionHeader(cs, 'FKM'),
_card(cs, [
_toggleRow(
cs,
icon: Symbols.notifications_active,
label: 'Включить уведомления',
subtitle:
'Для работы FKM уведомлений, приложению понадобится держать уведомление в шторке.',
value: _fkmEnabled,
onChanged: (v) => setState(() => _fkmEnabled = v),
),
]),
const SizedBox(height: 20),
_sectionHeader(cs, 'Настройки уведомлений'),
_card(cs, [
_toggleRow(
cs,
icon: Symbols.person,
label: 'Уведомления от личных чатов',
value: _personalChatsEnabled,
onChanged: (v) => setState(() => _personalChatsEnabled = v),
),
_divider(cs),
_toggleRow(
cs,
icon: Symbols.groups,
label: 'Уведомления от групп',
value: _groupsEnabled,
onChanged: (v) => setState(() => _groupsEnabled = v),
),
_divider(cs),
_toggleRow(
cs,
icon: Symbols.campaign,
label: 'Уведомления от каналов',
value: _channelsEnabled,
onChanged: (v) => setState(() => _channelsEnabled = v),
),
]),
const SizedBox(height: 20),
_sectionHeader(cs, 'Звук'),
_card(cs, [
_tappableRow(
cs,
icon: Symbols.music_note,
label: 'Звук уведомления',
trailingText: _selectedSound,
onTap: _pickSound,
),
]),
],
),
),
);
}
Widget _sectionHeader(ColorScheme cs, String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Text(
title,
style: TextStyle(
color: cs.primary,
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
);
}
Widget _card(ColorScheme cs, List<Widget> children) {
return Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: Column(children: children),
);
}
Widget _divider(ColorScheme cs) {
return Padding(
padding: const EdgeInsets.only(left: 58),
child: Divider(
height: 1,
thickness: 1,
color: cs.outlineVariant.withValues(alpha: 0.35),
),
);
}
Widget _toggleRow(
ColorScheme cs, {
required IconData icon,
required String label,
String? subtitle,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => onChanged(!value),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
child: Row(
children: [
Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
height: 1.3,
),
),
],
],
),
),
const SizedBox(width: 12),
Switch(value: value, onChanged: onChanged),
],
),
),
),
);
}
Widget _tappableRow(
ColorScheme cs, {
required IconData icon,
required String label,
required String trailingText,
required VoidCallback onTap,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17),
child: Row(
children: [
Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400),
const SizedBox(width: 16),
Expanded(
child: Text(
label,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
Text(
trailingText,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
),
),
const SizedBox(width: 6),
Icon(Symbols.chevron_right, color: cs.outline, size: 20),
],
),
),
),
);
}
}
+13 -2
View File
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../backend/modules/chats.dart';
import '../../../backend/modules/messages.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/token_storage.dart';
@@ -20,6 +21,7 @@ import 'debug_menu_screen.dart';
import 'devices_screen.dart';
import 'edit_profile_screen.dart';
import 'info_screen.dart';
import 'notifications_screen.dart';
import 'security_screen.dart';
import 'spoof_screen.dart';
@@ -206,6 +208,7 @@ class _SettingsTabState extends State<SettingsTab> {
}
ContactCache.clear();
TranscriptionCache.clear();
ChatsModule.resetForAccountSwitch();
try {
await api.connect();
} catch (_) {}
@@ -309,9 +312,17 @@ child: _buildSection(
context,
cs,
items: [
const _SettingsItem(
_SettingsItem(
icon: Symbols.notifications_active,
label: 'Уведомления и звук',
label: 'Уведомления',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const NotificationsScreen(),
),
);
},
),
_SettingsItem(
icon: Symbols.vibration,
+108 -25
View File
@@ -1,4 +1,5 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:komet/main.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -59,6 +60,7 @@ class MessageBubble extends StatelessWidget {
final CachedMessage? nextMessage;
final String chatType;
final String? overrideStatus;
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
const MessageBubble({
super.key,
@@ -69,6 +71,7 @@ class MessageBubble extends StatelessWidget {
this.nextMessage,
required this.chatType,
this.overrideStatus,
this.reactionsListenable,
});
bool _computeHasPhotoWithCaption() {
@@ -304,32 +307,52 @@ class MessageBubble extends StatelessWidget {
radius: 15,
backgroundColor: Color(0x00000000),
),
ListenableBuilder(
listenable: Listenable.merge(
[AppBubbleShape.current, AppBubbleBehavior.current],
),
builder: (context, child) {
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width * 0.75,
Column(
crossAxisAlignment:
isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
ListenableBuilder(
listenable: Listenable.merge(
[AppBubbleShape.current, AppBubbleBehavior.current],
),
decoration: BoxDecoration(
color: isMe
? cs.primaryContainer
: cs.surfaceContainerHighest,
borderRadius: _borderRadiusFor(
AppBubbleShape.current.value,
AppBubbleBehavior.current.value,
shape,
hasPhotoCap,
hasMultiPhotos,
),
),
padding: padding,
child: child,
);
},
child: _buildContent(ctx),
builder: (context, child) {
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width * 0.75,
),
decoration: BoxDecoration(
color: isMe
? cs.primaryContainer
: cs.surfaceContainerHighest,
borderRadius: _borderRadiusFor(
AppBubbleShape.current.value,
AppBubbleBehavior.current.value,
shape,
hasPhotoCap,
hasMultiPhotos,
),
),
padding: padding,
child: child,
);
},
child: _buildContent(ctx),
),
AnimatedSize(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
alignment: isMe
? Alignment.centerRight
: Alignment.centerLeft,
child: reactionsListenable != null
? ValueListenableBuilder<Map<String, dynamic>?>(
valueListenable: reactionsListenable!,
builder: (context, info, _) =>
_buildReactionsBarFor(cs, info),
)
: _buildReactionsBar(cs),
),
],
),
],
),
@@ -351,6 +374,66 @@ class MessageBubble extends StatelessWidget {
}
}
Widget _buildReactionsBar(ColorScheme cs) {
final info = message.payload?['reactionInfo'];
return _buildReactionsBarFor(cs, info is Map ? info : null);
}
Widget _buildReactionsBarFor(ColorScheme cs, Map? info) {
if (info == null) return const SizedBox.shrink();
final counters = info['counters'];
if (counters is! List || counters.isEmpty) return const SizedBox.shrink();
final yourReaction = info['yourReaction']?.toString();
final chips = <Widget>[];
for (final c in counters) {
if (c is! Map) continue;
final reaction = c['reaction']?.toString();
final count = c['count'];
if (reaction == null || reaction.isEmpty) continue;
final isYours = yourReaction == reaction;
chips.add(
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isYours
? cs.primary.withValues(alpha: 0.18)
: cs.surfaceContainerHighest.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isYours
? cs.primary.withValues(alpha: 0.45)
: cs.outlineVariant.withValues(alpha: 0.35),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(reaction, style: const TextStyle(fontSize: 14)),
if (count is int && count > 1) ...[
const SizedBox(width: 4),
Text(
count.toString(),
style: TextStyle(
color: isYours ? cs.primary : cs.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
);
}
if (chips.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(spacing: 4, runSpacing: 4, children: chips),
);
}
Widget _buildControlContent(ColorScheme cs) {
final attachments = message.attachments;
if (attachments == null || attachments.isEmpty) {