From d359e7147050255864aad719b5b1ca6e6d9a968f Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 19 Apr 2026 12:30:44 +0700 Subject: [PATCH 01/43] =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D0=BB=20=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D0=BD=D0=BE=20=D0=B7=D0=B0=20=D0=B8=D0=BD=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 22 ++++--- lib/frontend/screens/chats/chat_screen.dart | 39 ++++++++++++ lib/frontend/widgets/message_bubble.dart | 59 +++++++++---------- 3 files changed, 81 insertions(+), 39 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 986cbf2..d00b667 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1042,6 +1042,7 @@ class _ChatListScreenState extends State isOnline: chat.isOnline, unreadCount: chat.unreadCount, isMuted: chat.dontDisturbUntil > 0, + chatType: "DIALOG", ); } else { if (chat.lastMsgSenderId != null ) { @@ -1077,6 +1078,7 @@ class _ChatListScreenState extends State isOnline: chat.isOnline, unreadCount: chat.unreadCount, isMuted: chat.dontDisturbUntil > 0, + chatType: chat.type, ); } }, childCount: _isInitialLoading ? 10 : chats.length), @@ -1679,6 +1681,7 @@ class _ChatListScreenState extends State bool isRead = false, int unreadCount = 0, bool isMuted = false, + String chatType = "CHAT", }) { final cs = Theme.of(context).colorScheme; final isSelected = _selectedChats.contains(id); @@ -1688,16 +1691,17 @@ class _ChatListScreenState extends State if (_isSelectionMode) { _toggleSelection(id); } else { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatScreen( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, +Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, + ), ), - ), - ); + ); } }, onLongPress: () => _toggleSelection(id), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 28fb06d..f127ce2 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -14,12 +14,14 @@ class ChatScreen extends StatefulWidget { final int chatId; final String name; final String imageUrl; + final String chatType; const ChatScreen({ super.key, required this.chatId, required this.name, required this.imageUrl, + required this.chatType, }); @override @@ -469,6 +471,43 @@ class _ChatScreenState extends State Widget _buildInputArea(BuildContext context) { final cs = Theme.of(context).colorScheme; final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85); + + if (widget.chatType == "CHANNEL") { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: GestureDetector( + onTap: () {}, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: Color.alphaBlend( + cs.surfaceContainerHighest.withValues(alpha: 0.92), + cs.surface, + ), + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + ), + child: Center( + child: Text( + 'Отключить уведомления', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ); + } + return SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 74c4ed9..819b7cc 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -296,41 +296,41 @@ class MessageBubble extends StatelessWidget { return Padding( padding: EdgeInsets.only( - left: isMe ? 60 : 12, - right: isMe ? 12 : 60, + left: isMe ? 12 : 12, + right: isMe ? 12 : 12, top: topMargin, bottom: bottomMargin, ), child: Align( child: Row( mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, - spacing: 8.0, + crossAxisAlignment: CrossAxisAlignment.end, children: [ - if (senderAvatar != null && senderAvatar.isNotEmpty && !isMe && chatType != "DIALOG" - && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) - CircleAvatar( - radius: 15, - backgroundImage: NetworkImage(senderAvatar), - backgroundColor: cs.primaryContainer, - ) - else if (displaySender != null && !isMe && chatType != "DIALOG" - && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) - CircleAvatar( - radius: 15, - backgroundColor: cs.primaryContainer, - child: Text( - displaySender!.isNotEmpty - ? displaySender[0].toUpperCase() - : '?', - style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), - ), - ) - // Заглушка для паддинга - else - CircleAvatar( - radius: 15, - backgroundColor: Color(0x00000000) - ), + if (!isMe && chatType == "CHAT" && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) + ...(senderAvatar != null && senderAvatar.isNotEmpty) + ? [ + CircleAvatar( + radius: 15, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + ] + : [ + CircleAvatar( + radius: 15, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender != null && displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ) + ] + else if (!isMe && chatType != "CHAT") + SizedBox(width: 0) + else if (!isMe) + CircleAvatar(radius: 15, backgroundColor: Color(0x00000000)), Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.75, @@ -389,11 +389,10 @@ class MessageBubble extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (message.senderId != message.accountId && prevMessage?.senderId != message.senderId) + if (message.senderId != message.accountId && prevMessage?.senderId != message.senderId && chatType == "CHAT") Text( displaySender ?? "", textAlign: TextAlign.left, - // TODO: Получение цветов по хешу ника style: TextStyle(color: cs.onPrimaryContainer) ), Row( From 28ff49ca526c600dd8b57f29298280950769634e Mon Sep 17 00:00:00 2001 From: Jganenok Date: Tue, 21 Apr 2026 17:31:36 +0700 Subject: [PATCH 02/43] =?UTF-8?q?=D1=85=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 31 ++++++++++++------- .../screens/chats/chat_list_screen.dart | 6 ---- lib/frontend/widgets/message_bubble.dart | 4 --- lib/models/attachment.dart | 28 ++++++++--------- 4 files changed, 34 insertions(+), 35 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 00dd1c0..1526cac 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -75,13 +75,13 @@ class CachedMessage { } return CachedMessage( - id: row['id'] as String, - accountId: row['account_id'] as int, - chatId: row['chat_id'] as int, - senderId: row['sender_id'] as int, - text: row['text'] as String?, - time: row['time'] as int, - status: row['status'] as String?, + id: row['id']?.toString() ?? '', + accountId: row['account_id'] is int ? row['account_id'] as int : int.tryParse(row['account_id']?.toString() ?? '') ?? 0, + chatId: row['chat_id'] is int ? row['chat_id'] as int : int.tryParse(row['chat_id']?.toString() ?? '') ?? 0, + senderId: row['sender_id'] is int ? row['sender_id'] as int : int.tryParse(row['sender_id']?.toString() ?? '') ?? 0, + text: row['text']?.toString(), + time: row['time'] is int ? row['time'] as int : int.tryParse(row['time']?.toString() ?? '') ?? 0, + status: row['status']?.toString(), payload: payload, attachments: attachments, ); @@ -209,15 +209,22 @@ class MessagesModule { id: id, accountId: accountId, chatId: chatId, - senderId: (m['sender'] as int?) ?? 0, - text: m['text'] as String?, - time: (m['time'] as int?) ?? 0, - status: m['status'] as String?, + senderId: _parseIntField(m['sender']), + text: m['text']?.toString(), + time: _parseIntField(m['time']), + status: m['status']?.toString(), payload: Map.from(m.cast()), attachments: attachments, ); } + int _parseIntField(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is String) return int.tryParse(value) ?? 0; + return int.tryParse(value.toString()) ?? 0; + } + Future sendMessage( int accountId, int chatId, @@ -356,6 +363,8 @@ class MessagesModule { final cached = ContactCache.get(contactId); if (cached != null) return cached; + if (_api.state != SessionState.online) return null; + try { final response = await _api.sendRequest(Opcode.contactInfo, { 'contactIds': [contactId], diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index d00b667..9a9878b 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1028,8 +1028,6 @@ class _ChatListScreenState extends State if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { final secondId = chat.participants.entries.where((entry) => entry.key != _profile?.id).first.key; - // TODO: Нормальное кеширование контактов - final ss = messagesModule.searchContactById(secondId); final name = ContactCache.get(secondId); final avatar = ContactCache.getAvatar(secondId); @@ -1045,10 +1043,6 @@ class _ChatListScreenState extends State chatType: "DIALOG", ); } else { - if (chat.lastMsgSenderId != null ) { - final ss = messagesModule.searchContactById(chat.lastMsgSenderId!); - } - final name = chat.lastMsgSenderId != null ? ContactCache.get(chat.lastMsgSenderId!) : null; diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 819b7cc..1fc12c2 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -289,8 +289,6 @@ class MessageBubble extends StatelessWidget { final cs = Theme.of(context).colorScheme; final isDark = cs.brightness == Brightness.dark; - // TODO: Нормальное кеширование контактов - final ss = messagesModule.searchContactById(message.senderId); String? senderAvatar = ContactCache.getAvatar(message.senderId); String? displaySender = ContactCache.get(message.senderId); @@ -382,8 +380,6 @@ class MessageBubble extends StatelessWidget { final forwarded = _getForwardedAttachment(); final isForwarded = forwarded != null && !isForwardedContact; - // TODO: Нормальное кеширование контактов - final ss = messagesModule.searchContactById(message.senderId); String? displaySender = ContactCache.get(message.senderId); return Column( diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 2d3b6b1..25ce0a6 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -344,15 +344,15 @@ class ContactAttachment extends MessageAttachment { factory ContactAttachment.fromMap(Map map) { return ContactAttachment( - previewData: map['previewData'] as String?, - baseUrl: map['baseUrl'] as String?, - userId: map['userId'] as String?, - firstName: map['firstName'] as String?, - lastName: map['lastName'] as String?, - phoneNumber: map['phoneNumber'] as String?, - photoUrl: map['photoUrl'] as String?, - contactId: map['contactId'] as int?, - name: map['name'] as String?, + previewData: map['previewData']?.toString(), + baseUrl: map['baseUrl']?.toString(), + userId: map['userId']?.toString(), + firstName: map['firstName']?.toString(), + lastName: map['lastName']?.toString(), + phoneNumber: map['phoneNumber']?.toString(), + photoUrl: map['photoUrl']?.toString(), + contactId: map['contactId'] is int ? map['contactId'] as int : int.tryParse(map['contactId']?.toString() ?? ''), + name: map['name']?.toString(), ); } @@ -426,11 +426,11 @@ class ControlAttachment extends MessageAttachment { factory ControlAttachment.fromMap(Map map) { return ControlAttachment( - previewData: map['previewData'] as String?, - baseUrl: map['baseUrl'] as String?, - event: map['event'] as String?, - title: map['title'] as String?, - userIds: (map['userIds'] as List?)?.cast(), + previewData: map['previewData']?.toString(), + baseUrl: map['baseUrl']?.toString(), + event: map['event']?.toString(), + title: map['title']?.toString(), + userIds: (map['userIds'] as List?)?.map((e) => e is int ? e : int.tryParse(e?.toString() ?? '') ?? 0).toList(), ); } From bf49c6639d88d54e7ec8e13a7fe70990f9414ed7 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 21 Apr 2026 18:59:35 +0300 Subject: [PATCH 03/43] =?UTF-8?q?fix:=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=B0=D1=82=D1=83=D1=80=D1=8B=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5=20=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/code_confirmation_screen.dart | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 3840751..512a938 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -34,13 +34,14 @@ class _CodeConfirmationScreenState extends State late AnimationController _shakeController; late Animation _shakeAnimation; + bool _keyboardScheduled = false; + Animation? _routeAnimation; + AnimationStatusListener? _routeAnimationListener; + @override void initState() { super.initState(); _startTimer(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); _shakeController = AnimationController( vsync: this, @@ -56,8 +57,17 @@ class _CodeConfirmationScreenState extends State ]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.linear)); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _scheduleKeyboardOpen(); + } + @override void dispose() { + if (_routeAnimationListener != null) { + _routeAnimation?.removeStatusListener(_routeAnimationListener!); + } _timer?.cancel(); _errorTimer?.cancel(); _shakeController.dispose(); @@ -66,6 +76,36 @@ class _CodeConfirmationScreenState extends State super.dispose(); } + void _scheduleKeyboardOpen() { + if (_keyboardScheduled) return; + _keyboardScheduled = true; + + final animation = ModalRoute.of(context)?.animation; + if (animation == null || animation.status == AnimationStatus.completed) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _openKeyboard(); + }); + return; + } + + _routeAnimation = animation; + _routeAnimationListener = (status) { + if (status == AnimationStatus.completed) { + animation.removeStatusListener(_routeAnimationListener!); + _routeAnimationListener = null; + if (mounted) _openKeyboard(); + } + }; + animation.addStatusListener(_routeAnimationListener!); + } + + void _openKeyboard() { + if (!_focusNode.hasFocus) { + _focusNode.requestFocus(); + } + SystemChannels.textInput.invokeMethod('TextInput.show'); + } + void _startTimer() { _timer?.cancel(); _timerSeconds = 30; @@ -215,7 +255,7 @@ class _CodeConfirmationScreenState extends State ), ), GestureDetector( - onTap: () => _focusNode.requestFocus(), + onTap: _openKeyboard, child: FittedBox( child: Row( children: List.generate(6, (index) { From ff93286021aac143c21297e7036ce563397985d6 Mon Sep 17 00:00:00 2001 From: InviseDivine Date: Tue, 28 Apr 2026 19:11:43 +0200 Subject: [PATCH 04/43] chat info not finished --- .../screens/chats/chat_info_screen.dart | 61 ++++++++ lib/frontend/screens/chats/chat_screen.dart | 140 ++++++++++-------- lib/frontend/widgets/message_bubble.dart | 114 +++++++------- 3 files changed, 198 insertions(+), 117 deletions(-) create mode 100644 lib/frontend/screens/chats/chat_info_screen.dart diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart new file mode 100644 index 0000000..c50ae93 --- /dev/null +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +class ChatInfoScreen extends StatefulWidget { + final int chatId; + final String name; + final String imageUrl; + final String chatType; + + const ChatInfoScreen({ + super.key, + required this.chatId, + required this.name, + required this.imageUrl, + required this.chatType, + }); + @override + State createState() => _ChatInfoScreenState(); +} +class _ChatInfoScreenState extends State{ + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + + ), + body: + SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (widget.imageUrl.isNotEmpty) + CircleAvatar( + radius: 36, + backgroundImage: NetworkImage(widget.imageUrl), + ) + else + CircleAvatar( + radius: 36, + backgroundColor: cs.primaryContainer, + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12), + ), + ), + Text( + widget.name, + style: TextStyle(color: Colors.white, fontSize: 24, height: 1.3), + ), + + ], + ), + ), + ); + } +} + diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index f127ce2..41069ac 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:komet/backend/modules/chats.dart'; +import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; import '../../../backend/api.dart'; @@ -256,71 +257,84 @@ class _ChatScreenState extends State String? status = chat?.type == "CHAT" ? "${chat?.participants.length.toString()} участников" : "last seen recently"; return Scaffold( backgroundColor: cs.surface, - appBar: AppBar( - backgroundColor: cs.surfaceContainerHigh, - foregroundColor: cs.onSurface, - elevation: 0, - surfaceTintColor: Colors.transparent, - iconTheme: IconThemeData(color: cs.onSurface), - leading: IconButton( - icon: const Icon(Symbols.arrow_back, weight: 400), - onPressed: () => Navigator.pop(context), - ), - titleSpacing: 0, - title: Row( - children: [ - if (widget.imageUrl.isNotEmpty) - CircleAvatar( - radius: 18, - backgroundImage: NetworkImage(widget.imageUrl), - ) - else - CircleAvatar( - radius: 18, - backgroundColor: cs.primaryContainer, - child: Text( - widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.name, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - Text( - status ?? "", - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 12, - fontWeight: FontWeight.w400, - ), - ), - ], - ), + appBar: PreferredSize( + preferredSize: Size.fromHeight(kToolbarHeight), + child: InkWell( + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => ChatInfoScreen( + chatId: widget.chatId, + name: widget.name, + imageUrl: widget.imageUrl, + chatType: widget.chatType) + ) + ), + child: AppBar( + backgroundColor: cs.surfaceContainerHigh, + foregroundColor: cs.onSurface, + elevation: 0, + surfaceTintColor: Colors.transparent, + iconTheme: IconThemeData(color: cs.onSurface), + leading: IconButton( + icon: const Icon(Symbols.arrow_back, weight: 400), + onPressed: () => Navigator.pop(context), ), - ], - ), - actions: [ - IconButton( - icon: const Icon(Symbols.call, weight: 400), - onPressed: () {}, + titleSpacing: 0, + title: Row( + children: [ + if (widget.imageUrl.isNotEmpty) + CircleAvatar( + radius: 18, + backgroundImage: NetworkImage(widget.imageUrl), + ) + else + CircleAvatar( + radius: 18, + backgroundColor: cs.primaryContainer, + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + Text( + status ?? "", + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Symbols.call, weight: 400), + onPressed: () {}, + ), + IconButton( + icon: const Icon(Symbols.more_vert, weight: 400), + onPressed: () {}, + ), + ], ), - IconButton( - icon: const Icon(Symbols.more_vert, weight: 400), - onPressed: () {}, - ), - ], - ), + )), body: Column( children: [ Expanded( diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 1fc12c2..1217554 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -292,61 +292,67 @@ class MessageBubble extends StatelessWidget { String? senderAvatar = ContactCache.getAvatar(message.senderId); String? displaySender = ContactCache.get(message.senderId); - return Padding( - padding: EdgeInsets.only( - left: isMe ? 12 : 12, - right: isMe ? 12 : 12, - top: topMargin, - bottom: bottomMargin, - ), - child: Align( - child: Row( - mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (!isMe && chatType == "CHAT" && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) - ...(senderAvatar != null && senderAvatar.isNotEmpty) - ? [ - CircleAvatar( - radius: 15, - backgroundImage: NetworkImage(senderAvatar), - backgroundColor: cs.primaryContainer, - ) - ] - : [ - CircleAvatar( - radius: 15, - backgroundColor: cs.primaryContainer, - child: Text( - displaySender != null && displaySender.isNotEmpty - ? displaySender[0].toUpperCase() - : '?', - style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), - ), - ) - ] - else if (!isMe && chatType != "CHAT") - SizedBox(width: 0) - else if (!isMe) - CircleAvatar(radius: 15, backgroundColor: Color(0x00000000)), - Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.75, + return GestureDetector( + // TODO: действия с сообщением + onTap: () => print("test"), + child: Padding( + padding: EdgeInsets.only( + left: isMe ? 12 : 12, + right: isMe ? 12 : 12, + top: topMargin, + bottom: bottomMargin, + ), + child: Align( + child: Row( + mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + spacing: 8, + + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isMe && chatType == "CHAT" && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) + ...(senderAvatar != null && senderAvatar.isNotEmpty) + ? [ + CircleAvatar( + radius: 15, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + ] + : [ + CircleAvatar( + radius: 15, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender != null && displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ) + ] + else if (!isMe && chatType != "CHAT") + SizedBox(width: 0) + else if (!isMe) + CircleAvatar(radius: 15, backgroundColor: Color(0x00000000)), + Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + decoration: BoxDecoration( + color: isMe + ? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF)) + : (isDark + ? cs.surfaceContainerHighest + : const Color(0xFFE9E9EB)), + borderRadius: _borderRadius, + ), + padding: padding, + child: _buildContent(context), ), - decoration: BoxDecoration( - color: isMe - ? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF)) - : (isDark - ? cs.surfaceContainerHighest - : const Color(0xFFE9E9EB)), - borderRadius: _borderRadius, - ), - padding: padding, - child: _buildContent(context), - ), - ], - ) - ), + ], + ) + ), + ) ); } From 72ac52b9b5b7dc95f0b55410ad66af48f70c6b9e Mon Sep 17 00:00:00 2001 From: prime Date: Wed, 29 Apr 2026 19:02:44 +1000 Subject: [PATCH 05/43] =?UTF-8?q?=D1=80=D1=83=D0=BA=D0=B8=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BB=D0=BE=D0=BC=D0=B0=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 10 +++++- lib/backend/api.dart | 6 ++++ lib/backend/models/chat_folder.dart | 2 +- lib/backend/modules/account.dart | 28 +++++++++------- lib/backend/modules/calls.dart | 4 +-- lib/backend/modules/chats.dart | 32 +++++++++++++------ lib/backend/modules/contacts.dart | 12 +++---- lib/backend/modules/messages.dart | 19 ++++++----- lib/core/protocol/packet.dart | 1 + lib/core/storage/app_database.dart | 28 +++++++++++++--- lib/core/storage/token_storage.dart | 2 +- lib/core/transport/connection.dart | 4 ++- lib/core/transport/proxy_connector.dart | 24 ++++++++++---- lib/core/transport/sender.dart | 2 +- .../screens/auth/server_settings_sheet.dart | 4 ++- .../screens/chats/chat_list_screen.dart | 1 + lib/frontend/screens/chats/chat_screen.dart | 2 +- .../screens/profile/devices_screen.dart | 5 ++- lib/main.dart | 13 +++++--- lib/models/attachment.dart | 12 ++++--- 20 files changed, 145 insertions(+), 66 deletions(-) diff --git a/.gitignore b/.gitignore index 14601b5..f395ff9 100644 --- a/.gitignore +++ b/.gitignore @@ -125,4 +125,12 @@ app.*.symbols !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages -!/dev/ci/**/Gemfile.lock \ No newline at end of file +!/dev/ci/**/Gemfile.lock + +# AI / Agents +agents.md +.claude/ + +# Environment variables +.env +.env.* \ No newline at end of file diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 2c50950..495b56a 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -237,6 +237,11 @@ class Api { _dispatcher.registerHandler(opcode, handler); } + /// Снимает обработчик пушей с указанного опкода. + void unregisterPushHandler(int opcode) { + _dispatcher.unregisterHandler(opcode); + } + /// Стрим всех входящих пушей от сервера. Stream get pushStream => _dispatcher.pushStream; @@ -248,6 +253,7 @@ class Api { _connection.dispose(); _stateController.close(); _sessionExpiredController.close(); + _handshakeSuccessController.close(); } // Внутрянка diff --git a/lib/backend/models/chat_folder.dart b/lib/backend/models/chat_folder.dart index 0d6044a..8392a6c 100644 --- a/lib/backend/models/chat_folder.dart +++ b/lib/backend/models/chat_folder.dart @@ -25,7 +25,7 @@ class ChatFolder { factory ChatFolder.fromJson(Map json) { return ChatFolder( - id: json['id'].toString(), + id: json['id']?.toString() ?? '', title: json['title']?.toString() ?? '', emoji: json['emoji']?.toString(), include: (json['include'] as List?) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 1f57233..4c5c32d 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -289,7 +289,7 @@ class LoginSyncParams { draftsSync: int.tryParse(values[SyncKey.draftsSync] ?? '') ?? 0, bannersSync: int.tryParse(values[SyncKey.bannersSync] ?? '') ?? 0, presenceSync: int.tryParse(values[SyncKey.presenceSync] ?? '') ?? -1, - lastLogin: int.parse(lastLogin), + lastLogin: int.tryParse(lastLogin) ?? 0, configHash: values[SyncKey.configHash], chatCacheFingerprint: values[SyncKey.chatCacheFingerprint], ); @@ -646,19 +646,25 @@ class AccountModule { Future _processProfileUpdate(Packet packet) async { _api.registerPushHandler(Opcode.notifProfile, (p) {}); - await for (final push in _api.pushStream.where( - (p) => p.opcode == Opcode.notifProfile, - )) { - final payload = push.payload; - if (payload is Map) { - final profile = payload['profile']; - if (profile is Map) { - final contact = profile['contact']; - if (contact is Map) { - return ProfileData.fromServerMap(contact.cast()); + try { + await for (final push in _api.pushStream + .where((p) => p.opcode == Opcode.notifProfile) + .timeout(const Duration(seconds: 15))) { + final payload = push.payload; + if (payload is Map) { + final profile = payload['profile']; + if (profile is Map) { + final contact = profile['contact']; + if (contact is Map) { + return ProfileData.fromServerMap(contact.cast()); + } } } } + } on TimeoutException { + throw Exception('Таймаут ожидания обновления профиля'); + } finally { + _api.unregisterPushHandler(Opcode.notifProfile); } throw Exception('Не удалось получить обновлённый профиль'); } diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index 4593e48..cc2db6a 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -92,8 +92,8 @@ class CallsModule { msg['id']?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(); - final name = contact?.firstName != null - ? '${contact!.firstName} ${contact.lastName ?? ''}'.trim() + final name = (contact != null && contact.firstName.isNotEmpty) + ? '${contact.firstName} ${contact.lastName ?? ''}'.trim() : 'Неизвестный'; extractedCalls.add( diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 3b0fb27..7ee2864 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -3,6 +3,21 @@ import 'dart:convert'; import '../../core/storage/app_database.dart'; import '../../core/utils/logger.dart'; +Map _parseParticipants(dynamic raw) { + try { + final decoded = raw is String ? jsonDecode(raw) : raw; + if (decoded is Map) { + return decoded.map((k, v) => MapEntry( + k is int ? k : int.parse(k.toString()), + v is int ? v : int.tryParse(v.toString()) ?? 0, + )); + } + } catch (e) { + logger.e('Failed to parse participants: $e'); + } + return {}; +} + class CachedChat { final int id; final int accountId; @@ -59,8 +74,7 @@ class CachedChat { dontDisturbUntil: row['dont_disturb_until'] as int, isOnline: (row['is_online'] as int) == 1, seenTime: row['seen_time'] as int, - // watafuc - participants: Map.from(jsonDecode(row['participants'])).map((k, v) => MapEntry(int.parse(k), v)) + participants: _parseParticipants(row['participants']) ); Map toDbRow() => { @@ -242,7 +256,7 @@ class ChatsModule { isOnline = (presence['status'] as int?) == 1; } } - Map participants = Map.from(chat['participants']); + Map participants = _parseParticipants(chat['participants']); return CachedChat( id: id, @@ -282,12 +296,12 @@ class ChatsModule { static String? _nameFromContact(Map contact) { final names = contact['names']; if (names is! List || names.isEmpty) return null; - final name = - names.firstWhere( - (n) => n is Map && n['type'] == 'ONEME', - orElse: () => names.first, - ) - as Map; + final nameRaw = names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.firstWhere((n) => n is Map, orElse: () => null), + ); + if (nameRaw is! Map) return null; + final name = nameRaw; return name['name'] as String?; } } diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index 1943e52..71e4926 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -72,12 +72,12 @@ class ContactsModule { final names = contact['names']; if (names is List && names.isNotEmpty) { - final name = - names.firstWhere( - (n) => n is Map && n['type'] == 'ONEME', - orElse: () => names.first, - ) - as Map; + final nameRaw = names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.firstWhere((n) => n is Map, orElse: () => null), + ); + if (nameRaw is! Map) return null; + final name = nameRaw; firstName = (name['firstName'] as String?) ?? ''; lastName = name['lastName'] as String?; } diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 1526cac..255ff2e 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -155,7 +155,9 @@ class MessagesModule { } if (rows.isNotEmpty) { - AppDatabase.saveMessages(rows).ignore(); + AppDatabase.saveMessages(rows).catchError((e) { + debugPrint('saveMessages error: $e'); + }); } return results; @@ -257,9 +259,8 @@ class MessagesModule { if (data is! Map) return null; final content = data['content']; - if (content is String) { - return Uri.parse(content).host.isNotEmpty ? null : null; - } + if (content is Uint8List) return content; + if (content is List) return Uint8List.fromList(content); return null; } catch (e) { return null; @@ -295,9 +296,8 @@ class MessagesModule { if (data is! Map) return null; final content = data['content']; - if (content is String) { - return Uri.parse(content).host.isNotEmpty ? null : null; - } + if (content is Uint8List) return content; + if (content is List) return Uint8List.fromList(content); return null; } catch (e) { return null; @@ -333,9 +333,8 @@ class MessagesModule { if (data is! Map) return null; final content = data['content']; - if (content is String) { - return Uri.parse(content).host.isNotEmpty ? null : null; - } + if (content is Uint8List) return content; + if (content is List) return Uint8List.fromList(content); return null; } catch (e) { return null; diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 7b6fb38..5637328 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -190,6 +190,7 @@ Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) { if (pos >= src.length) break; + if (pos + 1 >= src.length) throw StateError('LZ4: unexpected end of input'); final offset = src[pos] | (src[pos + 1] << 8); pos += 2; if (offset == 0) throw StateError('LZ4: offset = 0'); diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 5890f21..53bbb91 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:komet/core/utils/logger.dart'; @@ -72,10 +73,15 @@ class ProfileData { final profileOptionsStr = row['profile_options'] as String?; List? profileOptions; if (profileOptionsStr != null && profileOptionsStr.isNotEmpty) { - profileOptions = profileOptionsStr - .split(',') - .map((e) => int.parse(e.trim())) - .toList(); + try { + profileOptions = profileOptionsStr + .split(',') + .where((e) => e.trim().isNotEmpty) + .map((e) => int.parse(e.trim())) + .toList(); + } catch (_) { + profileOptions = null; + } } return ProfileData( id: row['id'] as int, @@ -130,8 +136,20 @@ class AppDatabase { } } + static Completer? _initCompleter; + static Future get _instance async { - _db ??= await _open(); + if (_db != null) return _db!; + if (_initCompleter != null) return _initCompleter!.future; + _initCompleter = Completer(); + try { + _db = await _open(); + _initCompleter!.complete(_db!); + } catch (e) { + _initCompleter!.completeError(e); + _initCompleter = null; + rethrow; + } return _db!; } diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index 75ab4bb..b04ed77 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -33,7 +33,7 @@ class TokenStorage { static Future readActiveToken() async { final id = await getActiveAccountId(); if (id == null) return null; - return readToken(id); + return await readToken(id); } static Future deleteAccount(int accountId) async { diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index 15c3a53..c0b0532 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -99,7 +99,9 @@ class Connection { if (socket != null) { try { socket.close(); - } catch (_) {} + } catch (e) { + logger.w('Ошибка при закрытии сокета: $e'); + } } _setState(SocketState.disconnected); diff --git a/lib/core/transport/proxy_connector.dart b/lib/core/transport/proxy_connector.dart index aaac29b..90d4df6 100644 --- a/lib/core/transport/proxy_connector.dart +++ b/lib/core/transport/proxy_connector.dart @@ -60,8 +60,8 @@ class ProxyConnector { if (!useAuth) { throw SocketException('SOCKS5: прокси требует аутентификацию'); } - final usernameBytes = utf8.encode(settings.username!); - final passwordBytes = utf8.encode(settings.password!); + final usernameBytes = utf8.encode(settings.username ?? ''); + final passwordBytes = utf8.encode(settings.password ?? ''); final authPacket = BytesBuilder() ..addByte(0x01) ..addByte(usernameBytes.length) @@ -175,7 +175,10 @@ class ProxyConnector { final responseStr = utf8.decode(headerBytes, allowMalformed: true); final statusLine = responseStr.split('\r\n').first; final parts = statusLine.split(' '); - final statusCode = parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0; + if (parts.length < 2) { + throw SocketException('HTTP CONNECT: некорректный ответ: $statusLine'); + } + final statusCode = int.tryParse(parts[1]) ?? 0; if (statusCode != 200) { throw SocketException( 'HTTP CONNECT: прокси вернул статус $statusCode', @@ -201,10 +204,17 @@ class ProxyConnector { RawSocket proxySocket, _RawSocketIO io, ) async { - final server = await RawServerSocket.bind( - InternetAddress.loopbackIPv4, - 0, - ); + RawServerSocket? server; + try { + server = await RawServerSocket.bind( + InternetAddress.loopbackIPv4, + 0, + ); + } catch (e) { + io.dispose(); + proxySocket.close(); + rethrow; + } final clientSide = await RawSocket.connect( InternetAddress.loopbackIPv4, server.port, diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart index 69e8c5d..aaa3187 100644 --- a/lib/core/transport/sender.dart +++ b/lib/core/transport/sender.dart @@ -8,7 +8,7 @@ class PacketSender { int get currentSeq => _seq; int _nextSeq() { - _seq = (_seq + 1) % 256; + _seq = (_seq + 1) % 65536; return _seq; } diff --git a/lib/frontend/screens/auth/server_settings_sheet.dart b/lib/frontend/screens/auth/server_settings_sheet.dart index 534338a..e458642 100644 --- a/lib/frontend/screens/auth/server_settings_sheet.dart +++ b/lib/frontend/screens/auth/server_settings_sheet.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -53,7 +55,7 @@ class _ServerSettingsSheetState extends State { await prefs.setString(ServerConfig.prefHostKey, host); await prefs.setInt(ServerConfig.prefPortKey, port); await api.disconnect(); - api.connect(); + unawaited(api.connect()); final online = await api.stateStream .firstWhere((s) => s == SessionState.online || s == SessionState.disconnected) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 9a9878b..be2dacf 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -638,6 +638,7 @@ class _ChatListScreenState extends State ..removeListener(_onStoriesRevealTick) ..removeStatusListener(_onStoriesRevealStatus) ..dispose(); + _shimmerController.dispose(); _folderPageController.dispose(); while (_folderChatScrollControllers.isNotEmpty) { final c = _folderChatScrollControllers.removeLast(); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 41069ac..22c8d0c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -381,7 +381,7 @@ class _ChatScreenState extends State myId: _myId, prevMessage: prevMessage, nextMessage: nextMessage, - chatType: chat!.type, + chatType: chat?.type ?? 'CHAT', ); }, ); diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index 8030921..ab9b715 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -271,8 +271,9 @@ class _DevicesScreenState extends State setState(() => _loadingIps.add(id)); } + HttpClient? client; try { - final client = HttpClient(); + client = HttpClient(); client.connectionTimeout = const Duration(seconds: 5); final request = await client.getUrl( Uri.parse( @@ -296,6 +297,8 @@ class _DevicesScreenState extends State setState(() => _loadingIps.remove(id)); showCustomNotification(context, 'Ошибка IP: $e'); } + } finally { + client?.close(); } } diff --git a/lib/main.dart b/lib/main.dart index 0ce4998..5c62627 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -71,6 +73,7 @@ class KometAppState extends State { late Locale _locale; bool _isLoggingOut = false; + StreamSubscription? _sessionExpiredSub; late final ValueNotifier fpsOverlayEnabled = ValueNotifier( widget.initialFpsOverlay, ); @@ -83,15 +86,16 @@ class KometAppState extends State { api.setReconnectCallback(() async { try { final accountId = await TokenStorage.getActiveAccountId(); - if (accountId != null && - await TokenStorage.readToken(accountId) != null) { + if (accountId != null) { final token = await TokenStorage.readToken(accountId); - await accountModule.login(accountId: accountId, token: token); + if (token != null) { + await accountModule.login(accountId: accountId, token: token); + } } } catch (_) {} }); - api.sessionExpiredStream.listen((SessionExpiredException e) async { + _sessionExpiredSub = api.sessionExpiredStream.listen((SessionExpiredException e) async { if (_isLoggingOut) return; _isLoggingOut = true; @@ -118,6 +122,7 @@ class KometAppState extends State { @override void dispose() { + _sessionExpiredSub?.cancel(); fpsOverlayEnabled.dispose(); super.dispose(); } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 25ce0a6..825e348 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -136,7 +136,8 @@ class VideoAttachment extends MessageAttachment { } else if (previewRaw is List) { try { final bytes = List.from(previewRaw); - previewStr = String.fromCharCodes(bytes); + final base64 = String.fromCharCodes(bytes); + previewStr = 'data:image/webp;base64,$base64'; } catch (_) {} } @@ -192,7 +193,8 @@ class AudioAttachment extends MessageAttachment { } else if (previewRaw is List) { try { final bytes = List.from(previewRaw); - previewStr = String.fromCharCodes(bytes); + final base64 = String.fromCharCodes(bytes); + previewStr = 'data:image/webp;base64,$base64'; } catch (_) {} } @@ -244,7 +246,8 @@ class FileAttachment extends MessageAttachment { } else if (previewRaw is List) { try { final bytes = List.from(previewRaw); - previewStr = String.fromCharCodes(bytes); + final base64 = String.fromCharCodes(bytes); + previewStr = 'data:image/webp;base64,$base64'; } catch (_) {} } @@ -294,7 +297,8 @@ class StickerAttachment extends MessageAttachment { } else if (previewRaw is List) { try { final bytes = List.from(previewRaw); - previewStr = String.fromCharCodes(bytes); + final base64 = String.fromCharCodes(bytes); + previewStr = 'data:image/webp;base64,$base64'; } catch (_) {} } From a0d319a2488b9a52e60caf37a97ff46ba34053ad Mon Sep 17 00:00:00 2001 From: prime Date: Wed, 29 Apr 2026 19:21:53 +1000 Subject: [PATCH 06/43] =?UTF-8?q?=D0=BD=D1=83=20=D0=B7=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=20=D1=85=D1=83=D0=B9=D0=BD=D1=8E=20=D0=BD=D0=B0=D1=88=D0=B5?= =?UTF-8?q?=D0=BB.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/calls/calls_tab.dart | 8 +++++++- lib/frontend/screens/chats/chat_list_screen.dart | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 09edce7..7e08f23 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -30,7 +30,13 @@ class _CallsTabState extends State { } final callsModule = CallsModule(api); - final calls = await callsModule.fetchHistory(p.id, p.id); + List calls; + try { + calls = await callsModule.fetchHistory(p.id, p.id); + } catch (e) { + if (mounted) setState(() => _isLoading = false); + return; + } final List grouped = []; for (final call in calls) { diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index be2dacf..b52a632 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1255,6 +1255,7 @@ class _ChatListScreenState extends State }); }, child: Stack( + clipBehavior: Clip.hardEdge, children: [ AnimatedPositioned( duration: _navDragging From ea3042f341e8e9a4ab732c8195a6b7df12f1f782 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 13 May 2026 21:26:46 +0700 Subject: [PATCH 07/43] =?UTF-8?q?=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=BE=D0=B4=D1=80=D0=BE=D0=B1=D0=BD=D0=B5=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 120 ++++++ lib/backend/modules/chats.dart | 22 + lib/core/storage/app_database.dart | 14 + lib/core/storage/spoofing_service.dart | 2 +- .../screens/chats/chat_info_screen.dart | 379 ++++++++++++++++-- .../screens/profile/debug_menu_screen.dart | 132 +++++- lib/frontend/screens/profile/info_screen.dart | 286 +++++++++++++ .../screens/profile/security_screen.dart | 25 +- .../screens/profile/settings_tab.dart | 21 +- lib/l10n/app_en.arb | 57 ++- lib/l10n/app_localizations.dart | 324 +++++++++++++++ lib/l10n/app_localizations_en.dart | 162 ++++++++ lib/l10n/app_localizations_ru.dart | 162 ++++++++ lib/l10n/app_ru.arb | 57 ++- 14 files changed, 1724 insertions(+), 39 deletions(-) create mode 100644 lib/frontend/screens/profile/info_screen.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 4c5c32d..8d6f6b8 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -27,6 +27,7 @@ class PrivacyConfig { final String chatsInvite; final bool pushNewContacts; final bool unsafeFiles; + final String phoneNumberPrivacy; final String inactiveTtl; final bool showReadMark; final bool altKeyboard; @@ -48,6 +49,7 @@ class PrivacyConfig { required this.chatsInvite, required this.pushNewContacts, required this.unsafeFiles, + required this.phoneNumberPrivacy, required this.inactiveTtl, required this.showReadMark, required this.altKeyboard, @@ -71,6 +73,7 @@ class PrivacyConfig { chatsInvite: map['CHATS_INVITE']?.toString() ?? 'CONTACTS', pushNewContacts: map['PUSH_NEW_CONTACTS'] ?? false, unsafeFiles: map['UNSAFE_FILES'] ?? true, + phoneNumberPrivacy: map['PHONE_NUMBER_PRIVACY']?.toString() ?? 'ALL', inactiveTtl: map['INACTIVE_TTL']?.toString() ?? '6M', showReadMark: map['SHOW_READ_MARK'] ?? true, altKeyboard: map['ALT_KEYBOARD'] ?? false, @@ -94,6 +97,7 @@ class PrivacyConfig { 'CHATS_INVITE': chatsInvite, 'PUSH_NEW_CONTACTS': pushNewContacts, 'UNSAFE_FILES': unsafeFiles, + 'PHONE_NUMBER_PRIVACY': phoneNumberPrivacy, 'INACTIVE_TTL': inactiveTtl, 'SHOW_READ_MARK': showReadMark, 'ALT_KEYBOARD': altKeyboard, @@ -125,6 +129,7 @@ class PrivacyConfig { chatsInvite: 'CONTACTS', pushNewContacts: false, unsafeFiles: true, + phoneNumberPrivacy: 'ALL', inactiveTtl: '6M', showReadMark: true, altKeyboard: false, @@ -946,6 +951,12 @@ class AccountModule { logger.w('Папки чатов: $e'); } + try { + await _saveLoginInfo(data, profile.id); + } catch (e) { + logger.w('Info: $e'); + } + return LoginResult( profile: profile, updatedToken: updatedToken, @@ -980,6 +991,115 @@ class AccountModule { } } + Future _saveLoginInfo( + Map data, + int accountId, + ) async { + final contact = data['profile']?['contact'] as Map?; + final videoChatHistory = data['videoChatHistory']; + final chats = data['chats'] as List?; + final config = data['config'] as Map?; + final serverConfig = config?['server'] as Map?; + final userConfig = config?['user'] as Map?; + final yMap = serverConfig?['y-map'] as Map?; + final whiteListLinks = serverConfig?['white-list-links'] as List?; + final fileUploadUnsupported = serverConfig?['file-upload-unsupported-types'] as List?; + final time = data['time'] as int?; + + final info = { + 'registrationTime': contact?['registrationTime'], + 'country': contact?['country'], + 'videoChatHistory': videoChatHistory, + 'updateTime': contact?['updateTime'], + 'id': contact?['id'], + 'chatMarker': chats != null && chats.isNotEmpty + ? _extractChatMarker(chats.cast()) + : null, + 'time': time, + 'server': serverConfig != null + ? _extractServerInfo(serverConfig, yMap, whiteListLinks, fileUploadUnsupported) + : null, + 'user': userConfig != null ? _extractUserConfig(userConfig) : null, + }; + + await AppDatabase.saveLoginInfo(accountId, jsonEncode(info)); + } + + Map _extractChatMarker(List chats) { + int? latestTime; + for (final chat in chats) { + final lastEventTime = chat['lastEventTime'] as int?; + if (lastEventTime != null && (latestTime == null || lastEventTime > latestTime)) { + latestTime = lastEventTime; + } + } + return {'chatMarker': latestTime}; + } + + Map _extractServerInfo( + Map serverConfig, + Map? yMap, + List? whiteListLinks, + List? fileUploadUnsupported, + ) { + return { + 'account-removal-enabled': serverConfig['account-removal-enabled'], + 'image-size': serverConfig['image-size'], + 'gce': serverConfig['gce'], + 'gcce': serverConfig['gcce'], + 'max-msg-length': serverConfig['max-msg-length'], + 'quotes-enabled': serverConfig['quotes-enabled'], + 'calls-endpoint': serverConfig['calls-endpoint'], + 'send-location-enabled': serverConfig['send-location-enabled'], + 'lgce': serverConfig['lgce'], + 'wud': serverConfig['wud'], + 'video-msg-enabled': serverConfig['video-msg-enabled'], + 'grse': serverConfig['grse'], + 'edit-timeout': serverConfig['edit-timeout'], + 'image-quality': serverConfig['image-quality'], + 'unsafe-files-alert': serverConfig['unsafe-files-alert'], + 'account-nickname-enabled': serverConfig['account-nickname-enabled'], + 'mentions_entity_names_limit': serverConfig['mentions_entity_names_limit'], + 'reactions-enabled': serverConfig['reactions-enabled'], + 'y-map': yMap != null ? { + 'tile': yMap['tile'], + 'geocoder': yMap['geocoder'], + 'static': yMap['static'], + } : null, + 'white-list-links': whiteListLinks, + 'file-upload-unsupported-types': fileUploadUnsupported, + }; + } + + Map _extractUserConfig(Map userConfig) { + return { + 'CHATS_PUSH_NOTIFICATION': userConfig['CHATS_PUSH_NOTIFICATION'], + 'PUSH_DETAILS': userConfig['PUSH_DETAILS'], + 'PUSH_SOUND': userConfig['PUSH_SOUND'], + 'PHONE_NUMBER_PRIVACY': userConfig['PHONE_NUMBER_PRIVACY'], + 'INACTIVE_TTL': userConfig['INACTIVE_TTL'], + 'SHOW_READ_MARK': userConfig['SHOW_READ_MARK'], + 'AUDIO_TRANSCRIPTION_ENABLED': userConfig['AUDIO_TRANSCRIPTION_ENABLED'], + 'SEARCH_BY_PHONE': userConfig['SEARCH_BY_PHONE'], + 'INCOMING_CALL': userConfig['INCOMING_CALL'], + 'DOUBLE_TAP_REACTION_DISABLED': userConfig['DOUBLE_TAP_REACTION_DISABLED'], + 'SAFE_MODE_NO_PIN': userConfig['SAFE_MODE_NO_PIN'], + 'CHATS_PUSH_SOUND': userConfig['CHATS_PUSH_SOUND'], + 'DOUBLE_TAP_REACTION_VALUE': userConfig['DOUBLE_TAP_REACTION_VALUE'], + 'FAMILY_PROTECTION': userConfig['FAMILY_PROTECTION'], + 'HIDDEN': userConfig['HIDDEN'], + 'CHATS_INVITE': userConfig['CHATS_INVITE'], + 'PUSH_NEW_CONTACTS': userConfig['PUSH_NEW_CONTACTS'], + 'UNSAFE_FILES': userConfig['UNSAFE_FILES'], + 'DONT_DISTURB_UNTIL': userConfig['DONT_DISTURB_UNTIL'], + 'ALT_KEYBOARD': userConfig['ALT_KEYBOARD'], + 'CONTENT_LEVEL_ACCESS': userConfig['CONTENT_LEVEL_ACCESS'], + 'STICKERS_SUGGEST': userConfig['STICKERS_SUGGEST'], + 'SAFE_MODE': userConfig['SAFE_MODE'], + 'M_CALL_PUSH_NOTIFICATION': userConfig['M_CALL_PUSH_NOTIFICATION'], + }; + } + Future _requestCodeInternal( String phone, AuthRequestType type, diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 7ee2864..f47aa70 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import '../../core/protocol/opcode_map.dart'; import '../../core/storage/app_database.dart'; import '../../core/utils/logger.dart'; +import '../api.dart'; Map _parseParticipants(dynamic raw) { try { @@ -304,4 +306,24 @@ class ChatsModule { final name = nameRaw; return name['name'] as String?; } + + static Future?> getChatInfo(Api api, int chatId) async { + final packet = await api.sendRequest(Opcode.chatInfo, { + 'chatIds': [chatId], + }); + if (packet.isError) return null; + final payload = packet.payload as Map?; + final chats = payload?['chats'] as List?; + if (chats == null || chats.isEmpty) return null; + return Map.from(chats.first as Map); + } + + static Future searchById(Api api, int userId) async { + final packet = await api.sendRequest(Opcode.publicSearch, { + 'query': userId.toString(), + 'from': 0, + 'count': 10, + }); + return packet.payload; + } } diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 53bbb91..7283d9b 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -124,6 +124,7 @@ abstract class SyncKey { static const configHash = 'config_hash'; static const chatCacheFingerprint = 'chat_cache_fingerprint'; static const serverTime = 'server_time'; + static const loginInfo = 'login_info'; } class AppDatabase { @@ -392,6 +393,19 @@ class AppDatabase { return rows.first['value'] as String; } + static Future saveLoginInfo(int accountId, String jsonInfo) async { + final db = await _instance; + await db.insert('sync_state', { + 'account_id': accountId, + 'key': SyncKey.loginInfo, + 'value': jsonInfo, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future getLoginInfo(int accountId) async { + return getSyncValue(accountId, SyncKey.loginInfo); + } + static Future close() async { await _db?.close(); _db = null; diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart index b58cce2..c27240b 100644 --- a/lib/core/storage/spoofing_service.dart +++ b/lib/core/storage/spoofing_service.dart @@ -1,7 +1,7 @@ import 'package:shared_preferences/shared_preferences.dart'; class SpoofingService { - static const String hardcodedAppVersion = '26.8.1'; + static const String hardcodedAppVersion = '26.15.3'; static const int hardcodedBuildNumber = 6606; static Future?> getSpoofedSessionData() async { diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index c50ae93..70a4ae7 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -1,4 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/protocol/opcode_map.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart' as main; +import '../../widgets/custom_notification.dart'; class ChatInfoScreen extends StatefulWidget { final int chatId; @@ -13,49 +18,367 @@ class ChatInfoScreen extends StatefulWidget { required this.imageUrl, required this.chatType, }); + @override State createState() => _ChatInfoScreenState(); } -class _ChatInfoScreenState extends State{ + +class _ChatInfoScreenState extends State + with TickerProviderStateMixin { + bool _isLoading = true; + Map? _chatData; + late AnimationController _shimmerController; + + @override + void initState() { + super.initState(); + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + _loadChatData(); + } + + @override + void dispose() { + _shimmerController.dispose(); + super.dispose(); + } + + Future _loadChatData() async { + try { + final packet = await main.api.sendRequest(Opcode.chatInfo, { + 'chatIds': [widget.chatId], + }); + + final payload = packet.payload as Map?; + if (payload == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + + final errorField = payload['error']; + if (errorField != null) { + String errorMsg = 'Error'; + if (errorField is Map) { + errorMsg = errorField['localizedMessage'] ?? errorField['message'] ?? errorField.toString(); + } else if (errorField is String) { + errorMsg = errorField; + } + if (mounted) showCustomNotification(context, errorMsg); + setState(() => _isLoading = false); + return; + } + + final chats = payload['chats'] as List?; + if (chats != null && chats.isNotEmpty) { + _chatData = Map.from(chats.first as Map); + } else if (chats != null && chats.isEmpty) { + if (mounted) showCustomNotification(context, 'No data found'); + } + + if (mounted) setState(() => _isLoading = false); + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Error: $e'); + setState(() => _isLoading = false); + } + } + } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context); return Scaffold( backgroundColor: cs.surface, appBar: AppBar( - + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + l10n?.chatInfoTitle ?? 'Info', + style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), + ), ), - body: - SizedBox( - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (widget.imageUrl.isNotEmpty) - CircleAvatar( - radius: 36, - backgroundImage: NetworkImage(widget.imageUrl), - ) - else - CircleAvatar( - radius: 36, - backgroundColor: cs.primaryContainer, - child: Text( - widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12), - ), - ), - Text( - widget.name, - style: TextStyle(color: Colors.white, fontSize: 24, height: 1.3), + body: _isLoading + ? _buildShimmer(cs) + : _chatData == null + ? Center( + child: Text( + 'No data', + style: TextStyle(color: cs.onSurfaceVariant), + ), + ) + : _buildContent(cs, l10n), + ); + } + + Widget _buildShimmer(ColorScheme cs) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Center( + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + shape: BoxShape.circle, ), - - ], + ), + ), + const SizedBox(height: 12), + Center( + child: Container( + width: 120, + height: 20, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(6), + ), + ), + ), + const SizedBox(height: 24), + ...List.generate( + 10, + (_) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + height: 48, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ); + } + + Widget _buildContent(ColorScheme cs, AppLocalizations? l10n) { + final chat = _chatData!; + final type = chat['type'] as String? ?? ''; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Center( + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primaryContainer, + ), + child: widget.imageUrl.isNotEmpty + ? ClipOval( + child: Image.network(widget.imageUrl, fit: BoxFit.cover), + ) + : Center( + child: Text( + widget.name.isNotEmpty + ? widget.name[0].toUpperCase() + : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + const SizedBox(height: 12), + Center( + child: Text( + widget.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 24), + + if (type == 'CHANNEL') ...[ + _buildSectionTitle('Channel', cs), + _buildRow( + l10n?.chatInfoSubscribers ?? 'subscribers:', + (chat['participantsCount'] as int?)?.toString() ?? '-', + cs, + ), + if ((chat['link'] as String?)?.isNotEmpty ?? false) + _buildRow( + l10n?.chatInfoLink ?? 'link:', + chat['link'] as String, + cs, + ), + _buildRow( + l10n?.chatInfoOfficial ?? 'official:', + (chat['options']?['OFFICIAL'] as bool?)?.toString() ?? '-', + cs, + ), + _buildRow( + l10n?.chatInfoComments ?? 'comments:', + (chat['options']?['COMMENTS'] as bool?)?.toString() ?? '-', + cs, + ), + _buildRow( + l10n?.chatInfoAplus ?? 'approved by Roskomnadzor:', + (chat['options']?['A_PLUS_CHANNEL'] as bool?)?.toString() ?? '-', + cs, + ), + _buildRow( + l10n?.chatInfoSignAdmin ?? 'admin signature:', + (chat['options']?['SIGN_ADMIN'] as bool?)?.toString() ?? '-', + cs, + ), + if ((chat['modified'] as int?) != null) + _buildRow( + l10n?.chatInfoLastChanged ?? 'last changed:', + _formatTs(chat['modified'] as int), + cs, + ), + if ((chat['created'] as int?) != null) + _buildRow( + l10n?.chatInfoCreated ?? 'created:', + _formatTs(chat['created'] as int), + cs, + ), + ], + + if (type == 'CHAT') ...[ + _buildSectionTitle('Chat', cs), + _buildRow( + l10n?.chatInfoMembers ?? 'members:', + (chat['participantsCount'] as int?)?.toString() ?? '-', + cs, + ), + if ((chat['hasBots'] as bool?) ?? false) + _buildRow( + l10n?.chatInfoHasBots ?? 'has bots:', + 'true', + cs, + ), + if ((chat['blockedParticipantsCount'] as int?) != null && + chat['blockedParticipantsCount'] > 0) + _buildRow( + l10n?.chatInfoBlockedCount ?? 'blocked in group:', + (chat['blockedParticipantsCount'] as int).toString(), + cs, + ), + _buildRow( + l10n?.chatInfoOfficialStatus ?? 'official status:', + (chat['options']?['OFFICIAL'] as bool?)?.toString() ?? '-', + cs, + ), + if ((chat['modified'] as int?) != null) + _buildRow( + l10n?.chatInfoLastChanged ?? 'last changed:', + _formatTs(chat['modified'] as int), + cs, + ), + if ((chat['joinTime'] as int?) != null && chat['joinTime'] != 1) + _buildRow( + l10n?.chatInfoJoined ?? 'joined:', + _formatTs(chat['joinTime'] as int), + cs, + ), + if ((chat['created'] as int?) != null) + _buildRow( + l10n?.chatInfoGroupCreated ?? 'group created:', + _formatTs(chat['created'] as int), + cs, + ), + if ((chat['owner'] as int?) != null) + _buildRow( + l10n?.chatInfoGroupOwner ?? 'group owner:', + (chat['owner'] as int).toString(), + cs, + ), + ], + + if (type == 'DIALOG') ...[ + if ((chat['created'] as int?) != null && + chat['created'] != 0 && + chat['created'] != 1) + _buildRow( + l10n?.chatInfoDialogStarted ?? 'dialog started:', + _formatTs(chat['created'] as int), + cs, + ), + ], + + const SizedBox(height: 120), + ], + ); + } + + Widget _buildSectionTitle(String title, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4), + child: Text( + title, + style: TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, ), ), ); } -} + Widget _buildRow(String label, String value, ColorScheme cs) { + return Container( + margin: const EdgeInsets.only(bottom: 1), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + flex: 2, + child: Text( + label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 3, + child: Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.end, + ), + ), + ], + ), + ); + } + + String _formatTs(int ts) { + if (ts < 1000000000000) return ts.toString(); + final dt = DateTime.fromMillisecondsSinceEpoch(ts); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + } +} \ No newline at end of file diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 5f7de26..fc43f31 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -1,11 +1,55 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; - +import '../../../backend/modules/chats.dart'; +import '../../../core/utils/logger.dart'; import '../../../main.dart'; -class DebugMenuScreen extends StatelessWidget { +class DebugMenuScreen extends StatefulWidget { const DebugMenuScreen({super.key}); + @override + State createState() => _DebugMenuScreenState(); +} + +class _DebugMenuScreenState extends State { + final _idController = TextEditingController(); + String? _searchResult; + bool _isSearching = false; + + @override + void dispose() { + _idController.dispose(); + super.dispose(); + } + + Future _search() async { + final id = int.tryParse(_idController.text); + if (id == null) return; + setState(() { + _isSearching = true; + _searchResult = null; + }); + try { + final result = await ChatsModule.searchById(api, id); + logger.i('searchById result: $result'); + if (!mounted) return; + if (result is Map && result.containsKey('error')) { + final errorMsg = result['localizedMessage'] ?? result['message'] ?? result['error'] ?? 'Error'; + setState(() => _searchResult = 'Error: $errorMsg'); + } else if (result is Map) { + setState(() => _searchResult = result.toString()); + } else { + setState(() => _searchResult = result?.toString() ?? 'null'); + } + } catch (e) { + if (mounted) { + setState(() => _searchResult = 'Exception: $e'); + } + } finally { + if (mounted) setState(() => _isSearching = false); + } + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -115,10 +159,92 @@ class DebugMenuScreen extends StatelessWidget { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Поиск по ID (opcode 60)', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _idController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Введите user ID', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onSubmitted: (_) => _search(), + ), + ), + const SizedBox(width: 12), + FilledButton( + onPressed: _isSearching ? null : _search, + child: _isSearching + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Symbols.search, size: 20), + ), + ], + ), + if (_searchResult != null) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + constraints: const BoxConstraints(maxHeight: 400), + child: SingleChildScrollView( + child: Text( + _searchResult!, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ), + ), + ], + ], + ), + ), + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 120)), ], ), ), ); } -} +} \ No newline at end of file diff --git a/lib/frontend/screens/profile/info_screen.dart b/lib/frontend/screens/profile/info_screen.dart new file mode 100644 index 0000000..715ccd1 --- /dev/null +++ b/lib/frontend/screens/profile/info_screen.dart @@ -0,0 +1,286 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../core/storage/token_storage.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../widgets/custom_notification.dart'; + +class InfoScreen extends StatefulWidget { + const InfoScreen({super.key}); + + @override + State createState() => _InfoScreenState(); +} + +class _InfoScreenState extends State { + bool _isLoading = true; + Map? _info; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + try { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + final jsonStr = await AppDatabase.getLoginInfo(accountId); + if (jsonStr != null) { + setState(() => _info = jsonDecode(jsonStr) as Map); + } + if (mounted) setState(() => _isLoading = false); + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Error: $e'); + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context); + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + l10n?.infoTitle ?? 'Info', + style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _info == null + ? Center( + child: Text( + 'No data', + style: TextStyle(color: cs.onSurfaceVariant), + ), + ) + : _buildContent(cs, l10n!), + ); + } + + Widget _buildContent(ColorScheme cs, AppLocalizations l10n) { + final info = _info!; + final server = info['server'] as Map?; + final user = info['user'] as Map?; + final yMap = server?['y-map'] as Map?; + + final accountKeys = { + 'registrationTime': l10n.infoRegistrationTime, + 'country': l10n.infoCountry, + 'videoChatHistory': l10n.infoVideoChatHistory, + 'updateTime': l10n.infoUpdateTime, + 'id': l10n.infoId, + 'chatMarker': l10n.infoChatMarker, + }; + + final serverKeys = { + 'account-removal-enabled': l10n.infoAccountRemovalEnabled, + 'image-size': l10n.infoImageSize, + 'gce': l10n.infoGce, + 'gcce': l10n.infoGcce, + 'max-msg-length': l10n.infoMaxMsgLength, + 'quotes-enabled': l10n.infoQuotesEnabled, + 'calls-endpoint': l10n.infoCallsEndpoint, + 'send-location-enabled': l10n.infoSendLocationEnabled, + 'lgce': l10n.infoLgce, + 'wud': l10n.infoWud, + 'video-msg-enabled': l10n.infoVideoMsgEnabled, + 'grse': l10n.infoGrse, + 'edit-timeout': l10n.infoEditTimeout, + 'image-quality': l10n.infoImageQuality, + 'unsafe-files-alert': l10n.infoUnsafeFilesAlert, + 'account-nickname-enabled': l10n.infoAccountNicknameEnabled, + 'mentions_entity_names_limit': l10n.infoMentionsEntityNamesLimit, + 'reactions-enabled': l10n.infoReactionsEnabled, + }; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildSectionTitle(l10n.infoAccountSection, cs), + ...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key]), cs)), + + const SizedBox(height: 16), + _buildSectionTitle(l10n.infoServerSection, cs), + ...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key]), cs)), + + const SizedBox(height: 8), + _buildSectionTitle(l10n.infoYMapSection, cs), + _buildRow('tile', l10n.infoTile, yMap?['tile']?.toString() ?? '-', cs), + _buildRow('geocoder', l10n.infoGeocoder, yMap?['geocoder']?.toString() ?? '-', cs), + _buildRow('static', l10n.infoStatic, yMap?['static']?.toString() ?? '-', cs), + + const SizedBox(height: 8), + _buildSectionTitle(l10n.infoFileUploadTypes, cs), + _buildListRow(server?['file-upload-unsupported-types'] as List?, cs), + + const SizedBox(height: 8), + _buildSectionTitle(l10n.infoWhiteListLinks, cs), + _buildListRow(server?['white-list-links'] as List?, cs), + + const SizedBox(height: 8), + _buildSectionTitle(l10n.infoUserSection, cs), + if (user != null) + ...user.entries + .where((e) => e.value != null) + .map((e) => _buildRow(e.key, e.key, e.value.toString(), cs)), + + const SizedBox(height: 120), + ], + ); + } + + Widget _buildSectionTitle(String title, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4), + child: Text( + title, + style: TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ); + } + + Widget _buildRow(String key, String label, String value, ColorScheme cs) { + return Container( + margin: const EdgeInsets.only(bottom: 1), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + flex: 2, + child: Text( + label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 3, + child: Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.end, + ), + ), + ], + ), + ); + } + + Widget _buildListRow(List? items, ColorScheme cs) { + if (items == null || items.isEmpty) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Text('-', style: TextStyle(color: cs.onSurfaceVariant)), + ); + } + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Wrap( + spacing: 8, + runSpacing: 4, + children: items + .map( + (item) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + item.toString(), + style: TextStyle(fontSize: 13, color: cs.onSurface), + ), + ), + ) + .toList(), + ), + ); + } + + String _formatValue(dynamic value) { + if (value == null) return '-'; + if (value is Map && value.containsKey('chatMarker')) { + final ts = value['chatMarker'] as int?; + if (ts != null) { + final dt = DateTime.fromMillisecondsSinceEpoch(ts); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + } + return '-'; + } + if (value is int && value > 1000000000000) { + final dt = DateTime.fromMillisecondsSinceEpoch(value); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + } + if (value is int && value > 86400) { + final weeks = value ~/ 604800; + final days = (value % 604800) ~/ 86400; + if (weeks > 0) { + return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim(); + } + final h = value ~/ 3600; + final m = (value % 3600) ~/ 60; + if (h > 0) return '${h}h ${m}m'; + return '${m}m'; + } + return value.toString(); + } + + String _w(int n) { + final m = n % 10; + if (m == 1 && n != 11) return 'нед'; + if ((m == 2 || m == 3 || m == 4) && (n < 10 || n > 20)) return 'нед'; + return 'нед'; + } + + String _d(int n) { + final m = n % 10; + if (m == 1 && n != 11) return 'дн'; + if ((m == 2 || m == 3 || m == 4) && (n < 10 || n > 20)) return 'дн'; + return 'дн'; + } +} \ No newline at end of file diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index 676396e..e65ab62 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -222,6 +222,7 @@ class _SecurityScreenState extends State case 'CONTACTS': return 'Мои контакты'; case 'NONE': + case 'NOBODY': return 'Никто'; default: return value; @@ -481,9 +482,31 @@ class _SecurityScreenState extends State icon: Icons.visibility_off_outlined, label: 'Видеть статус «в сети»', value: _privacyConfig?.hidden == true ? 'Никто' : 'Мои контакты', - isLast: true, + isLast: false, onTap: () => _showHiddenStatusSheet(context, cs), ), + _buildOptionRow( + cs, + icon: Symbols.contact_page, + label: 'Видеть мой номер', + value: _getPrivacyLabel( + _privacyConfig?.phoneNumberPrivacy ?? 'ALL', + ), + isLast: true, + onTap: () => _showOptionSheet( + context, + cs, + title: 'Видеть мой номер', + currentValue: _privacyConfig?.phoneNumberPrivacy ?? 'ALL', + options: const [ + ('ALL', 'Все'), + ('CONTACTS', 'Мои контакты'), + ('NOBODY', 'Никто'), + ], + onSelect: (value) => + _updateSetting('PHONE_NUMBER_PRIVACY', value), + ), + ), ], ], ), diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 2389838..3804ac8 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -8,6 +8,7 @@ import '../../../l10n/app_localizations.dart'; import '../auth/proxy_settings_sheet.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; +import 'info_screen.dart'; import 'security_screen.dart'; import 'spoof_screen.dart'; @@ -96,15 +97,27 @@ class _SettingsTabState extends State { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: _buildSection( +child: _buildSection( context, cs, - items: const [ - _SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'), - _SettingsItem( + items: [ + const _SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'), + const _SettingsItem( icon: Symbols.language, label: 'Войти в Сферум', ), + _SettingsItem( + icon: Symbols.info, + label: 'Info', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const InfoScreen(), + ), + ); + }, + ), ], ), ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 806d0c5..174459b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -104,5 +104,60 @@ } } }, - "profileMenuSpoof": "Spoofing" + "profileMenuSpoof": "Spoofing", + "infoTitle": "Info", + "infoAccountSection": "Account", + "infoServerSection": "Server", + "infoUserSection": "User", + "infoYMapSection": "Y-Map", + "infoFileUploadTypes": "file-upload-unsupported-types", + "infoWhiteListLinks": "white-list-links", + "infoRegistrationTime": "registrationTime", + "infoCountry": "country", + "infoVideoChatHistory": "videoChatHistory", + "infoUpdateTime": "updateTime", + "infoId": "id", + "infoChatMarker": "chatMarker", + "infoAccountRemovalEnabled": "account-removal-enabled", + "infoImageSize": "image-size", + "infoGce": "gce", + "infoGcce": "gcce", + "infoMaxMsgLength": "max-msg-length", + "infoQuotesEnabled": "quotes-enabled", + "infoCallsEndpoint": "calls-endpoint", + "infoSendLocationEnabled": "send-location-enabled", + "infoLgce": "lgce", + "infoWud": "wud", + "infoVideoMsgEnabled": "video-msg-enabled", + "infoGrse": "grse", + "infoEditTimeout": "edit-timeout", + "infoImageQuality": "image-quality", + "infoUnsafeFilesAlert": "unsafe-files-alert", + "infoAccountNicknameEnabled": "account-nickname-enabled", + "infoMentionsEntityNamesLimit": "mentions_entity_names_limit", + "infoReactionsEnabled": "reactions-enabled", + "infoTile": "tile", + "infoGeocoder": "geocoder", + "infoStatic": "static", + "chatInfoSubscribers": "subscribers:", + "chatInfoInvitedBy": "invited by:", + "chatInfoLink": "link:", + "chatInfoOfficial": "official:", + "chatInfoComments": "comments:", + "chatInfoAplus": "approved by Roskomnadzor:", + "chatInfoSignAdmin": "admin signature:", + "chatInfoLastChanged": "last changed:", + "chatInfoJoinTime": "joined:", + "chatInfoCreated": "created:", + "chatInfoTitle": "Info", + "chatInfoMembers": "members:", + "chatInfoLastSeen": "last seen recently", + "chatInfoHasBots": "has bots:", + "chatInfoBlockedCount": "blocked in group:", + "chatInfoOfficialStatus": "official status:", + "chatInfoLastChanged": "last changed:", + "chatInfoJoined": "joined:", + "chatInfoGroupCreated": "group created:", + "chatInfoGroupOwner": "group owner:", + "chatInfoDialogStarted": "dialog started:" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5bd5013..15fbdfb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -631,6 +631,330 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Spoofing'** String get profileMenuSpoof; + + /// No description provided for @infoTitle. + /// + /// In en, this message translates to: + /// **'Info'** + String get infoTitle; + + /// No description provided for @infoAccountSection. + /// + /// In en, this message translates to: + /// **'Account'** + String get infoAccountSection; + + /// No description provided for @infoServerSection. + /// + /// In en, this message translates to: + /// **'Server'** + String get infoServerSection; + + /// No description provided for @infoUserSection. + /// + /// In en, this message translates to: + /// **'User'** + String get infoUserSection; + + /// No description provided for @infoYMapSection. + /// + /// In en, this message translates to: + /// **'Y-Map'** + String get infoYMapSection; + + /// No description provided for @infoFileUploadTypes. + /// + /// In en, this message translates to: + /// **'file-upload-unsupported-types'** + String get infoFileUploadTypes; + + /// No description provided for @infoWhiteListLinks. + /// + /// In en, this message translates to: + /// **'white-list-links'** + String get infoWhiteListLinks; + + /// No description provided for @infoRegistrationTime. + /// + /// In en, this message translates to: + /// **'registrationTime'** + String get infoRegistrationTime; + + /// No description provided for @infoCountry. + /// + /// In en, this message translates to: + /// **'country'** + String get infoCountry; + + /// No description provided for @infoVideoChatHistory. + /// + /// In en, this message translates to: + /// **'videoChatHistory'** + String get infoVideoChatHistory; + + /// No description provided for @infoUpdateTime. + /// + /// In en, this message translates to: + /// **'updateTime'** + String get infoUpdateTime; + + /// No description provided for @infoId. + /// + /// In en, this message translates to: + /// **'id'** + String get infoId; + + /// No description provided for @infoChatMarker. + /// + /// In en, this message translates to: + /// **'chatMarker'** + String get infoChatMarker; + + /// No description provided for @infoAccountRemovalEnabled. + /// + /// In en, this message translates to: + /// **'account-removal-enabled'** + String get infoAccountRemovalEnabled; + + /// No description provided for @infoImageSize. + /// + /// In en, this message translates to: + /// **'image-size'** + String get infoImageSize; + + /// No description provided for @infoGce. + /// + /// In en, this message translates to: + /// **'gce'** + String get infoGce; + + /// No description provided for @infoGcce. + /// + /// In en, this message translates to: + /// **'gcce'** + String get infoGcce; + + /// No description provided for @infoMaxMsgLength. + /// + /// In en, this message translates to: + /// **'max-msg-length'** + String get infoMaxMsgLength; + + /// No description provided for @infoQuotesEnabled. + /// + /// In en, this message translates to: + /// **'quotes-enabled'** + String get infoQuotesEnabled; + + /// No description provided for @infoCallsEndpoint. + /// + /// In en, this message translates to: + /// **'calls-endpoint'** + String get infoCallsEndpoint; + + /// No description provided for @infoSendLocationEnabled. + /// + /// In en, this message translates to: + /// **'send-location-enabled'** + String get infoSendLocationEnabled; + + /// No description provided for @infoLgce. + /// + /// In en, this message translates to: + /// **'lgce'** + String get infoLgce; + + /// No description provided for @infoWud. + /// + /// In en, this message translates to: + /// **'wud'** + String get infoWud; + + /// No description provided for @infoVideoMsgEnabled. + /// + /// In en, this message translates to: + /// **'video-msg-enabled'** + String get infoVideoMsgEnabled; + + /// No description provided for @infoGrse. + /// + /// In en, this message translates to: + /// **'grse'** + String get infoGrse; + + /// No description provided for @infoEditTimeout. + /// + /// In en, this message translates to: + /// **'edit-timeout'** + String get infoEditTimeout; + + /// No description provided for @infoImageQuality. + /// + /// In en, this message translates to: + /// **'image-quality'** + String get infoImageQuality; + + /// No description provided for @infoUnsafeFilesAlert. + /// + /// In en, this message translates to: + /// **'unsafe-files-alert'** + String get infoUnsafeFilesAlert; + + /// No description provided for @infoAccountNicknameEnabled. + /// + /// In en, this message translates to: + /// **'account-nickname-enabled'** + String get infoAccountNicknameEnabled; + + /// No description provided for @infoMentionsEntityNamesLimit. + /// + /// In en, this message translates to: + /// **'mentions_entity_names_limit'** + String get infoMentionsEntityNamesLimit; + + /// No description provided for @infoReactionsEnabled. + /// + /// In en, this message translates to: + /// **'reactions-enabled'** + String get infoReactionsEnabled; + + /// No description provided for @infoTile. + /// + /// In en, this message translates to: + /// **'tile'** + String get infoTile; + + /// No description provided for @infoGeocoder. + /// + /// In en, this message translates to: + /// **'geocoder'** + String get infoGeocoder; + + /// No description provided for @infoStatic. + /// + /// In en, this message translates to: + /// **'static'** + String get infoStatic; + + /// No description provided for @chatInfoSubscribers. + /// + /// In en, this message translates to: + /// **'subscribers:'** + String get chatInfoSubscribers; + + /// No description provided for @chatInfoInvitedBy. + /// + /// In en, this message translates to: + /// **'invited by:'** + String get chatInfoInvitedBy; + + /// No description provided for @chatInfoLink. + /// + /// In en, this message translates to: + /// **'link:'** + String get chatInfoLink; + + /// No description provided for @chatInfoOfficial. + /// + /// In en, this message translates to: + /// **'official:'** + String get chatInfoOfficial; + + /// No description provided for @chatInfoComments. + /// + /// In en, this message translates to: + /// **'comments:'** + String get chatInfoComments; + + /// No description provided for @chatInfoAplus. + /// + /// In en, this message translates to: + /// **'approved by Roskomnadzor:'** + String get chatInfoAplus; + + /// No description provided for @chatInfoSignAdmin. + /// + /// In en, this message translates to: + /// **'admin signature:'** + String get chatInfoSignAdmin; + + /// No description provided for @chatInfoLastChanged. + /// + /// In en, this message translates to: + /// **'last changed:'** + String get chatInfoLastChanged; + + /// No description provided for @chatInfoJoinTime. + /// + /// In en, this message translates to: + /// **'joined:'** + String get chatInfoJoinTime; + + /// No description provided for @chatInfoCreated. + /// + /// In en, this message translates to: + /// **'created:'** + String get chatInfoCreated; + + /// No description provided for @chatInfoTitle. + /// + /// In en, this message translates to: + /// **'Info'** + String get chatInfoTitle; + + /// No description provided for @chatInfoMembers. + /// + /// In en, this message translates to: + /// **'members:'** + String get chatInfoMembers; + + /// No description provided for @chatInfoLastSeen. + /// + /// In en, this message translates to: + /// **'last seen recently'** + String get chatInfoLastSeen; + + /// No description provided for @chatInfoHasBots. + /// + /// In en, this message translates to: + /// **'has bots:'** + String get chatInfoHasBots; + + /// No description provided for @chatInfoBlockedCount. + /// + /// In en, this message translates to: + /// **'blocked in group:'** + String get chatInfoBlockedCount; + + /// No description provided for @chatInfoOfficialStatus. + /// + /// In en, this message translates to: + /// **'official status:'** + String get chatInfoOfficialStatus; + + /// No description provided for @chatInfoJoined. + /// + /// In en, this message translates to: + /// **'joined:'** + String get chatInfoJoined; + + /// No description provided for @chatInfoGroupCreated. + /// + /// In en, this message translates to: + /// **'group created:'** + String get chatInfoGroupCreated; + + /// No description provided for @chatInfoGroupOwner. + /// + /// In en, this message translates to: + /// **'group owner:'** + String get chatInfoGroupOwner; + + /// No description provided for @chatInfoDialogStarted. + /// + /// In en, this message translates to: + /// **'dialog started:'** + String get chatInfoDialogStarted; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index bfce511..0320c05 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -289,4 +289,166 @@ class AppLocalizationsEn extends AppLocalizations { @override String get profileMenuSpoof => 'Spoofing'; + + @override + String get infoTitle => 'Info'; + + @override + String get infoAccountSection => 'Account'; + + @override + String get infoServerSection => 'Server'; + + @override + String get infoUserSection => 'User'; + + @override + String get infoYMapSection => 'Y-Map'; + + @override + String get infoFileUploadTypes => 'file-upload-unsupported-types'; + + @override + String get infoWhiteListLinks => 'white-list-links'; + + @override + String get infoRegistrationTime => 'registrationTime'; + + @override + String get infoCountry => 'country'; + + @override + String get infoVideoChatHistory => 'videoChatHistory'; + + @override + String get infoUpdateTime => 'updateTime'; + + @override + String get infoId => 'id'; + + @override + String get infoChatMarker => 'chatMarker'; + + @override + String get infoAccountRemovalEnabled => 'account-removal-enabled'; + + @override + String get infoImageSize => 'image-size'; + + @override + String get infoGce => 'gce'; + + @override + String get infoGcce => 'gcce'; + + @override + String get infoMaxMsgLength => 'max-msg-length'; + + @override + String get infoQuotesEnabled => 'quotes-enabled'; + + @override + String get infoCallsEndpoint => 'calls-endpoint'; + + @override + String get infoSendLocationEnabled => 'send-location-enabled'; + + @override + String get infoLgce => 'lgce'; + + @override + String get infoWud => 'wud'; + + @override + String get infoVideoMsgEnabled => 'video-msg-enabled'; + + @override + String get infoGrse => 'grse'; + + @override + String get infoEditTimeout => 'edit-timeout'; + + @override + String get infoImageQuality => 'image-quality'; + + @override + String get infoUnsafeFilesAlert => 'unsafe-files-alert'; + + @override + String get infoAccountNicknameEnabled => 'account-nickname-enabled'; + + @override + String get infoMentionsEntityNamesLimit => 'mentions_entity_names_limit'; + + @override + String get infoReactionsEnabled => 'reactions-enabled'; + + @override + String get infoTile => 'tile'; + + @override + String get infoGeocoder => 'geocoder'; + + @override + String get infoStatic => 'static'; + + @override + String get chatInfoSubscribers => 'subscribers:'; + + @override + String get chatInfoInvitedBy => 'invited by:'; + + @override + String get chatInfoLink => 'link:'; + + @override + String get chatInfoOfficial => 'official:'; + + @override + String get chatInfoComments => 'comments:'; + + @override + String get chatInfoAplus => 'approved by Roskomnadzor:'; + + @override + String get chatInfoSignAdmin => 'admin signature:'; + + @override + String get chatInfoLastChanged => 'last changed:'; + + @override + String get chatInfoJoinTime => 'joined:'; + + @override + String get chatInfoCreated => 'created:'; + + @override + String get chatInfoTitle => 'Info'; + + @override + String get chatInfoMembers => 'members:'; + + @override + String get chatInfoLastSeen => 'last seen recently'; + + @override + String get chatInfoHasBots => 'has bots:'; + + @override + String get chatInfoBlockedCount => 'blocked in group:'; + + @override + String get chatInfoOfficialStatus => 'official status:'; + + @override + String get chatInfoJoined => 'joined:'; + + @override + String get chatInfoGroupCreated => 'group created:'; + + @override + String get chatInfoGroupOwner => 'group owner:'; + + @override + String get chatInfoDialogStarted => 'dialog started:'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 7a1c8b4..d7780cf 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -291,4 +291,166 @@ class AppLocalizationsRu extends AppLocalizations { @override String get profileMenuSpoof => 'Подмена данных'; + + @override + String get infoTitle => 'Info'; + + @override + String get infoAccountSection => 'Аккаунт'; + + @override + String get infoServerSection => 'Сервер'; + + @override + String get infoUserSection => 'Пользователь'; + + @override + String get infoYMapSection => 'Y-Map'; + + @override + String get infoFileUploadTypes => 'запрещённые типы файлов'; + + @override + String get infoWhiteListLinks => 'безопасные ссылки'; + + @override + String get infoRegistrationTime => 'Дата регистрации:'; + + @override + String get infoCountry => 'Регион аккаунта:'; + + @override + String get infoVideoChatHistory => 'videoChatHistory'; + + @override + String get infoUpdateTime => 'Последнее обновление аватарки:'; + + @override + String get infoId => 'id аккаунта:'; + + @override + String get infoChatMarker => 'chatMarker'; + + @override + String get infoAccountRemovalEnabled => 'Мгновенное удаление аккаунта:'; + + @override + String get infoImageSize => 'image-size'; + + @override + String get infoGce => 'gce'; + + @override + String get infoGcce => 'gcce'; + + @override + String get infoMaxMsgLength => 'макс. длина сообщения:'; + + @override + String get infoQuotesEnabled => 'quotes-enabled'; + + @override + String get infoCallsEndpoint => 'calls-endpoint'; + + @override + String get infoSendLocationEnabled => 'отправка гео.:'; + + @override + String get infoLgce => 'lgce'; + + @override + String get infoWud => 'wud'; + + @override + String get infoVideoMsgEnabled => 'Кружки:'; + + @override + String get infoGrse => 'grse'; + + @override + String get infoEditTimeout => 'Можно редактировать сообщение в течении:'; + + @override + String get infoImageQuality => 'image-quality'; + + @override + String get infoUnsafeFilesAlert => 'unsafe-files-alert'; + + @override + String get infoAccountNicknameEnabled => 'account-nickname-enabled'; + + @override + String get infoMentionsEntityNamesLimit => 'макс. кол-во упоминаний:'; + + @override + String get infoReactionsEnabled => 'reactions-enabled'; + + @override + String get infoTile => 'tile'; + + @override + String get infoGeocoder => 'geocoder'; + + @override + String get infoStatic => 'static'; + + @override + String get chatInfoSubscribers => 'подписчиков:'; + + @override + String get chatInfoInvitedBy => 'Приглашён от:'; + + @override + String get chatInfoLink => 'ссылка:'; + + @override + String get chatInfoOfficial => 'оффициальный:'; + + @override + String get chatInfoComments => 'комментарии:'; + + @override + String get chatInfoAplus => 'подтверждён Роскомнадзором:'; + + @override + String get chatInfoSignAdmin => 'Подпись админов:'; + + @override + String get chatInfoLastChanged => 'последнее изменение:'; + + @override + String get chatInfoJoinTime => 'заход в канал:'; + + @override + String get chatInfoCreated => 'канал создан:'; + + @override + String get chatInfoTitle => 'Информация'; + + @override + String get chatInfoMembers => 'участников:'; + + @override + String get chatInfoLastSeen => 'был(а) недавно'; + + @override + String get chatInfoHasBots => 'Есть боты:'; + + @override + String get chatInfoBlockedCount => 'в ЧС группы:'; + + @override + String get chatInfoOfficialStatus => 'Официальный статус:'; + + @override + String get chatInfoJoined => 'Зашли в:'; + + @override + String get chatInfoGroupCreated => 'Группа создана в:'; + + @override + String get chatInfoGroupOwner => 'Создатель группы:'; + + @override + String get chatInfoDialogStarted => 'ЛС начат в:'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 0f2378c..c103607 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -104,5 +104,60 @@ } } }, - "profileMenuSpoof": "Подмена данных" + "profileMenuSpoof": "Подмена данных", + "infoTitle": "Info", + "infoAccountSection": "Аккаунт", + "infoServerSection": "Сервер", + "infoUserSection": "Пользователь", + "infoYMapSection": "Y-Map", + "infoFileUploadTypes": "запрещённые типы файлов", + "infoWhiteListLinks": "безопасные ссылки", + "infoRegistrationTime": "Дата регистрации:", + "infoCountry": "Регион аккаунта:", + "infoVideoChatHistory": "videoChatHistory", + "infoUpdateTime": "Последнее обновление аватарки:", + "infoId": "id аккаунта:", + "infoChatMarker": "chatMarker", + "infoAccountRemovalEnabled": "Мгновенное удаление аккаунта:", + "infoImageSize": "image-size", + "infoGce": "gce", + "infoGcce": "gcce", + "infoMaxMsgLength": "макс. длина сообщения:", + "infoQuotesEnabled": "quotes-enabled", + "infoCallsEndpoint": "calls-endpoint", + "infoSendLocationEnabled": "отправка гео.:", + "infoLgce": "lgce", + "infoWud": "wud", + "infoVideoMsgEnabled": "Кружки:", + "infoGrse": "grse", + "infoEditTimeout": "Можно редактировать сообщение в течении:", + "infoImageQuality": "image-quality", + "infoUnsafeFilesAlert": "unsafe-files-alert", + "infoAccountNicknameEnabled": "account-nickname-enabled", + "infoMentionsEntityNamesLimit": "макс. кол-во упоминаний:", + "infoReactionsEnabled": "reactions-enabled", + "infoTile": "tile", + "infoGeocoder": "geocoder", + "infoStatic": "static", + "chatInfoSubscribers": "подписчиков:", + "chatInfoInvitedBy": "Приглашён от:", + "chatInfoLink": "ссылка:", + "chatInfoOfficial": "оффициальный:", + "chatInfoComments": "комментарии:", + "chatInfoAplus": "подтверждён Роскомнадзором:", + "chatInfoSignAdmin": "Подпись админов:", + "chatInfoLastChanged": "последнее изменение:", + "chatInfoJoinTime": "заход в канал:", + "chatInfoCreated": "канал создан:", + "chatInfoTitle": "Информация", + "chatInfoMembers": "участников:", + "chatInfoLastSeen": "был(а) недавно", + "chatInfoHasBots": "Есть боты:", + "chatInfoBlockedCount": "в ЧС группы:", + "chatInfoOfficialStatus": "Официальный статус:", + "chatInfoLastChanged": "последнее изменение:", + "chatInfoJoined": "Зашли в:", + "chatInfoGroupCreated": "Группа создана в:", + "chatInfoGroupOwner": "Создатель группы:", + "chatInfoDialogStarted": "ЛС начат в:" } From 2991dd45009b666286c5f24f12f78fe390886993 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 13 May 2026 23:57:16 +0700 Subject: [PATCH 08/43] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 79 +++++- lib/core/storage/app_database.dart | 7 +- .../screens/profile/edit_profile_screen.dart | 252 ++++++++++++++++++ lib/frontend/screens/profile/info_screen.dart | 32 +-- .../screens/profile/settings_tab.dart | 19 +- lib/l10n/app_en.arb | 7 +- lib/l10n/app_localizations.dart | 30 +++ lib/l10n/app_localizations_en.dart | 15 ++ lib/l10n/app_localizations_ru.dart | 15 ++ lib/l10n/app_ru.arb | 7 +- lib/main.dart | 7 + 11 files changed, 445 insertions(+), 25 deletions(-) create mode 100644 lib/frontend/screens/profile/edit_profile_screen.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 8d6f6b8..288a097 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/material.dart' show Locale; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; @@ -431,6 +432,82 @@ class AccountModule { return config; } + Future updateProfileName(String firstName, String? lastName) async { + _ensureOnline(); + final payload = { + 'firstName': firstName, + }; + if (lastName != null) payload['lastName'] = lastName; + final packet = await _api.sendRequest(Opcode.profile, payload); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final profile = data['profile'] as Map?; + if (profile == null) throw Exception('No profile in response'); + final contact = profile['contact'] as Map?; + if (contact == null) throw Exception('No contact in response'); + final newProfile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(newProfile, isActive: true); + return newProfile; + } + + Future updateProfileAvatar(String photoToken, String avatarType) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.profile, { + 'photoToken': photoToken, + 'avatarType': avatarType, + }); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final profile = data['profile'] as Map?; + if (profile == null) throw Exception('No profile in response'); + final contact = profile['contact'] as Map?; + if (contact == null) throw Exception('No contact in response'); + final newProfile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(newProfile, isActive: true); + return newProfile; + } + + Future getAvatarUploadUrl() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.photoUpload, { + 'count': 1, + 'profile': true, + }); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final url = data['url'] as String?; + if (url == null) throw Exception('No url in response'); + return url; + } + + Future removeProfilePhoto(int photoId) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.removeContactPhoto, { + 'photoId': photoId, + }); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final profile = data['profile'] as Map?; + if (profile == null) throw Exception('No profile in response'); + final contact = profile['contact'] as Map?; + if (contact == null) throw Exception('No contact in response'); + final newProfile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(newProfile, isActive: true); + return newProfile; + } + // 2FA Creation (when not set) Future create2faTrack() async { _ensureOnline(); @@ -931,7 +1008,7 @@ class AccountModule { throw Exception('login: отсутствует profile.contact в ответе'); } final profile = ProfileData.fromServerMap(contact.cast()); - await AppDatabase.saveProfile(profile); + await AppDatabase.saveProfile(profile, isActive: true); await AppDatabase.setActiveAccount(profile.id); await _saveSyncState(data, serverTime, profile.id); diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 7283d9b..24f02d9 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -98,7 +98,7 @@ class ProfileData { ); } - Map toDbRow() => { + Map toDbRow({bool isActive = false}) => { 'id': id, 'first_name': firstName, 'last_name': lastName, @@ -109,6 +109,7 @@ class ProfileData { 'country': country, 'account_status': accountStatus, 'update_time': updateTime, + 'is_active': isActive ? 1 : 0, 'profile_options': profileOptions?.join(','), }; } @@ -280,11 +281,11 @@ class AppDatabase { ) '''; - static Future saveProfile(ProfileData profile) async { + static Future saveProfile(ProfileData profile, {bool isActive = true}) async { final db = await _instance; await db.insert( 'profile', - profile.toDbRow(), + profile.toDbRow(isActive: isActive), conflictAlgorithm: ConflictAlgorithm.replace, ); } diff --git a/lib/frontend/screens/profile/edit_profile_screen.dart b/lib/frontend/screens/profile/edit_profile_screen.dart new file mode 100644 index 0000000..11a94c5 --- /dev/null +++ b/lib/frontend/screens/profile/edit_profile_screen.dart @@ -0,0 +1,252 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart' show accountModule, KometApp; +import '../../widgets/custom_notification.dart'; + +class EditProfileScreen extends StatefulWidget { + const EditProfileScreen({super.key}); + + @override + State createState() => _EditProfileScreenState(); +} + +class _EditProfileScreenState extends State { + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + bool _isLoading = true; + bool _isSaving = false; + String? _avatarUrl; + int? _photoId; + + @override + void initState() { + super.initState(); + _loadProfile(); + } + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + super.dispose(); + } + + Future _loadProfile() async { + final profile = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + if (profile != null) { + _firstNameController.text = profile.firstName; + _lastNameController.text = profile.lastName ?? ''; + _avatarUrl = profile.baseUrl; + _photoId = profile.photoId; + setState(() => _isLoading = false); + } else { + setState(() => _isLoading = false); + } + } + + Future _saveName() async { + if (_isSaving) return; + final firstName = _firstNameController.text.trim(); + if (firstName.isEmpty) { + if (mounted) showCustomNotification(context, 'Имя не может быть пустым'); + return; + } + setState(() => _isSaving = true); + try { + final newProfile = await accountModule.updateProfileName( + firstName, + _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(), + ); + _avatarUrl = newProfile.baseUrl; + _photoId = newProfile.photoId; + KometApp.stateOf(context)?.notifyProfileUpdate(); + if (mounted) { + showCustomNotification(context, 'Имя сохранено'); + setState(() => _isSaving = false); + } + } catch (e) { + if (!mounted) return; + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isSaving = false); + } + } + + Future _changeAvatar() async { + if (_isSaving) return; + try { + final uploadUrl = await accountModule.getAvatarUploadUrl(); + if (!mounted) return; + showCustomNotification(context, 'Загрузка аватарки: $uploadUrl (пока нет)'); + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + } + } + + Future _removeAvatar() async { + if (_isSaving || _photoId == null) return; + setState(() => _isSaving = true); + try { + final newProfile = await accountModule.removeProfilePhoto(_photoId!); + _avatarUrl = newProfile.baseUrl; + _photoId = newProfile.photoId; + KometApp.stateOf(context)?.notifyProfileUpdate(); + if (mounted) { + showCustomNotification(context, 'Фото удалено'); + setState(() => _isSaving = false); + } + } catch (e) { + if (!mounted) return; + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context); + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + l10n?.editProfileTitle ?? 'Edit Profile', + style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), + ), + actions: [ + TextButton( + onPressed: _isLoading || _isSaving ? null : _saveName, + child: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + l10n?.editProfileSave ?? 'Save', + style: TextStyle(color: cs.primary, fontWeight: FontWeight.w600), + ), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + Center( + child: Stack( + children: [ + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.5), + width: 2.5, + ), + ), + child: ClipOval( + child: _avatarUrl != null && _avatarUrl!.isNotEmpty + ? Image.network(_avatarUrl!, fit: BoxFit.cover) + : Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + _firstNameController.text.isNotEmpty + ? _firstNameController.text[0].toUpperCase() + : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + ), + child: IconButton( + icon: Icon(Symbols.camera_alt, color: cs.onPrimary, size: 20), + onPressed: _changeAvatar, + ), + ), + ), + ], + ), + ), + if (_photoId != null) ...[ + const SizedBox(height: 8), + Center( + child: TextButton( + onPressed: _removeAvatar, + child: Text( + l10n?.editProfileRemovePhoto ?? 'Remove photo', + style: TextStyle(color: cs.error), + ), + ), + ), + ], + const SizedBox(height: 24), + _buildTextField( + l10n?.editProfileFirstName ?? 'First name', + _firstNameController, + cs, + enabled: !_isSaving, + ), + const SizedBox(height: 12), + _buildTextField( + l10n?.editProfileLastName ?? 'Last name', + _lastNameController, + cs, + enabled: !_isSaving, + ), + const SizedBox(height: 120), + ], + ), + ); + } + + Widget _buildTextField(String label, TextEditingController controller, ColorScheme cs, {bool enabled = true}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Text(label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + ), + TextField( + controller: controller, + enabled: enabled, + decoration: InputDecoration( + filled: true, + fillColor: cs.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/frontend/screens/profile/info_screen.dart b/lib/frontend/screens/profile/info_screen.dart index 715ccd1..58febca 100644 --- a/lib/frontend/screens/profile/info_screen.dart +++ b/lib/frontend/screens/profile/info_screen.dart @@ -115,11 +115,11 @@ class _InfoScreenState extends State { padding: const EdgeInsets.all(16), children: [ _buildSectionTitle(l10n.infoAccountSection, cs), - ...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key]), cs)), + ...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs)), const SizedBox(height: 16), _buildSectionTitle(l10n.infoServerSection, cs), - ...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key]), cs)), + ...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key], e.key), cs)), const SizedBox(height: 8), _buildSectionTitle(l10n.infoYMapSection, cs), @@ -240,28 +240,17 @@ class _InfoScreenState extends State { ); } - String _formatValue(dynamic value) { + String _formatValue(dynamic value, String key) { if (value == null) return '-'; if (value is Map && value.containsKey('chatMarker')) { final ts = value['chatMarker'] as int?; - if (ts != null) { - final dt = DateTime.fromMillisecondsSinceEpoch(ts); - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; - } - return '-'; + return ts != null ? _formatTs(ts) : '-'; } - if (value is int && value > 1000000000000) { - final dt = DateTime.fromMillisecondsSinceEpoch(value); - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; - } - if (value is int && value > 86400) { + if (value is int && value > 1000000000000) return _formatTs(value); + if (key == 'edit-timeout' && value is int && value > 0) { final weeks = value ~/ 604800; final days = (value % 604800) ~/ 86400; - if (weeks > 0) { - return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim(); - } + if (weeks > 0) return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim(); final h = value ~/ 3600; final m = (value % 3600) ~/ 60; if (h > 0) return '${h}h ${m}m'; @@ -270,6 +259,13 @@ class _InfoScreenState extends State { return value.toString(); } + String _formatTs(int ts) { + if (ts < 1000000000000) return ts.toString(); + final dt = DateTime.fromMillisecondsSinceEpoch(ts); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + } + String _w(int n) { final m = n % 10; if (m == 1 && n != 11) return 'нед'; diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index fd445cb..f38811b 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -6,9 +6,11 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; import '../../../l10n/app_localizations.dart'; +import '../../../main.dart'; import '../auth/proxy_settings_sheet.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; +import 'edit_profile_screen.dart'; import 'info_screen.dart'; import 'security_screen.dart'; import 'spoof_screen.dart'; @@ -27,17 +29,25 @@ class _SettingsTabState extends State { bool _debugMenuVisible = false; int _versionSecretTapCount = 0; Timer? _versionSecretTapResetTimer; + StreamSubscription? _profileUpdateSub; @override void initState() { super.initState(); _loadProfile(); _loadAppVersion(); + final appState = KometApp.stateOf(context); + if (appState != null) { + _profileUpdateSub = appState.profileUpdateStream.listen((_) { + if (mounted) _loadProfile(); + }); + } } @override void dispose() { _versionSecretTapResetTimer?.cancel(); + _profileUpdateSub?.cancel(); super.dispose(); } @@ -317,7 +327,14 @@ child: _buildSection( size: 22, weight: 400, ), - onPressed: () {}, + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const EditProfileScreen(), + ), + ); + }, ), ], ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 174459b..c7b227b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -159,5 +159,10 @@ "chatInfoJoined": "joined:", "chatInfoGroupCreated": "group created:", "chatInfoGroupOwner": "group owner:", - "chatInfoDialogStarted": "dialog started:" + "chatInfoDialogStarted": "dialog started:", + "editProfileTitle": "Edit Profile", + "editProfileSave": "Save", + "editProfileFirstName": "First name", + "editProfileLastName": "Last name", + "editProfileRemovePhoto": "Remove photo" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 15fbdfb..ce14093 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -955,6 +955,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'dialog started:'** String get chatInfoDialogStarted; + + /// No description provided for @editProfileTitle. + /// + /// In en, this message translates to: + /// **'Edit Profile'** + String get editProfileTitle; + + /// No description provided for @editProfileSave. + /// + /// In en, this message translates to: + /// **'Save'** + String get editProfileSave; + + /// No description provided for @editProfileFirstName. + /// + /// In en, this message translates to: + /// **'First name'** + String get editProfileFirstName; + + /// No description provided for @editProfileLastName. + /// + /// In en, this message translates to: + /// **'Last name'** + String get editProfileLastName; + + /// No description provided for @editProfileRemovePhoto. + /// + /// In en, this message translates to: + /// **'Remove photo'** + String get editProfileRemovePhoto; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0320c05..6f4c3c6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -451,4 +451,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chatInfoDialogStarted => 'dialog started:'; + + @override + String get editProfileTitle => 'Edit Profile'; + + @override + String get editProfileSave => 'Save'; + + @override + String get editProfileFirstName => 'First name'; + + @override + String get editProfileLastName => 'Last name'; + + @override + String get editProfileRemovePhoto => 'Remove photo'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d7780cf..999ea90 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -453,4 +453,19 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chatInfoDialogStarted => 'ЛС начат в:'; + + @override + String get editProfileTitle => 'Редактирование профиля'; + + @override + String get editProfileSave => 'Сохранить'; + + @override + String get editProfileFirstName => 'Имя'; + + @override + String get editProfileLastName => 'Фамилия'; + + @override + String get editProfileRemovePhoto => 'Удалить фото'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c103607..3d134a9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -159,5 +159,10 @@ "chatInfoJoined": "Зашли в:", "chatInfoGroupCreated": "Группа создана в:", "chatInfoGroupOwner": "Создатель группы:", - "chatInfoDialogStarted": "ЛС начат в:" + "chatInfoDialogStarted": "ЛС начат в:", + "editProfileTitle": "Редактирование профиля", + "editProfileSave": "Сохранить", + "editProfileFirstName": "Имя", + "editProfileLastName": "Фамилия", + "editProfileRemovePhoto": "Удалить фото" } diff --git a/lib/main.dart b/lib/main.dart index 5c62627..1fd803a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -77,6 +77,8 @@ class KometAppState extends State { late final ValueNotifier fpsOverlayEnabled = ValueNotifier( widget.initialFpsOverlay, ); + final _profileUpdateController = StreamController.broadcast(); + Stream get profileUpdateStream => _profileUpdateController.stream; @override void initState() { @@ -123,6 +125,7 @@ class KometAppState extends State { @override void dispose() { _sessionExpiredSub?.cancel(); + _profileUpdateController.close(); fpsOverlayEnabled.dispose(); super.dispose(); } @@ -147,6 +150,10 @@ class KometAppState extends State { } } + void notifyProfileUpdate() { + _profileUpdateController.add(null); + } + ColorScheme _adjustDarkScheme(ColorScheme base) { return base.copyWith( surface: Color.alphaBlend( From a51bae555b1bde6e8c57dcdbcc8226bdd8387a2c Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 14 May 2026 08:43:46 +0700 Subject: [PATCH 09/43] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2=D0=BE=20=D1=87=D0=B0=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B5=D1=81=D0=BB=D0=B8=20=D0=B2=20=D0=BD=D0=B5=D0=BC=20=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D1=81=D1=82=D0=B8=D0=BA=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/models/attachment.dart | 6 ++-- pubspec.lock | 64 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 825e348..fd7d35f 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -304,9 +304,9 @@ class StickerAttachment extends MessageAttachment { return StickerAttachment( previewData: previewStr, - baseUrl: map['baseUrl'] as String?, - stickerId: map['stickerId'] as String?, - stickerPackId: map['stickerPackId'] as String?, + baseUrl: map['baseUrl']?.toString(), + stickerId: map['stickerId']?.toString(), + stickerPackId: map['setId']?.toString() ?? map['stickerPackId']?.toString(), width: map['width'] as int?, height: map['height'] as int?, ); diff --git a/pubspec.lock b/pubspec.lock index 1f7d572..0cbc9e9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -129,11 +153,27 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_lints: dependency: "direct dev" description: @@ -373,6 +413,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_info_plus: dependency: "direct main" description: @@ -469,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shared_preferences: dependency: "direct main" description: @@ -658,6 +714,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: From e1846d86611dd6239802a064396619597f31ad8b Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 14 May 2026 12:34:07 +0700 Subject: [PATCH 10/43] =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=84?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B3=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 57 +++ lib/core/protocol/opcode_map.dart | 6 + lib/core/transport/dispatcher.dart | 6 +- lib/frontend/widgets/custom_notification.dart | 4 +- lib/frontend/widgets/message_bubble.dart | 457 ++++++++++++++---- lib/models/attachment.dart | 19 +- 6 files changed, 440 insertions(+), 109 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 255ff2e..150e5ca 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -23,6 +23,34 @@ class ContactCache { static String? getAvatar(int id) => _avatarCache[id]; } +class TranscriptionResult { + final int status; + final String? text; + final String? messageId; + final int? chatId; + final int? mediaId; + + TranscriptionResult({ + required this.status, + this.text, + this.messageId, + this.chatId, + this.mediaId, + }); +} + +class TranscriptionCache { + static final Map _cache = {}; + + static void put(String messageId, TranscriptionResult result) { + _cache[messageId] = result; + } + + static TranscriptionResult? get(String messageId) => _cache[messageId]; + + static bool has(String messageId) => _cache.containsKey(messageId); +} + class CachedMessage { final String id; final int accountId; @@ -247,6 +275,35 @@ class MessagesModule { await _api.sendRequest(Opcode.msgSend, payload); } + Future requestTranscription( + int chatId, + int messageId, + int mediaId, + ) async { + final payload = { + 'chatId': chatId, + 'messageId': messageId, + 'mediaId': mediaId, + }; + + final response = await _api.sendRequest(Opcode.audioTranscription, payload); + if (!response.isOk) return TranscriptionResult(status: -1); + + final data = response.payload; + if (data is! Map) return TranscriptionResult(status: -1); + + final transcriptionStatus = data['transcriptionStatus'] as int? ?? -1; + if (transcriptionStatus == 1) { + final text = data['transcription'] as String? ?? ''; + if (text.isEmpty) { + return TranscriptionResult(status: 1, text: 'не удалось распознать текст'); + } + return TranscriptionResult(status: 1, text: text); + } + + return TranscriptionResult(status: transcriptionStatus); + } + Future downloadPhoto(String baseUrl, String photoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index edc40e5..8f0f5c9 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -170,6 +170,10 @@ abstract class Opcode { static const int notifBanners = 292; // Баннеры static const int notifFolders = 277; // Обновление папок + // ── Transcription ─────────────────────────────────────────────────── + static const int audioTranscription = 202; // Запрос транскрибации аудио + static const int transcriptionResult = 293; // Результат транскрибации (push) + // ── Misc ─────────────────────────────────────────────────────────── static const int okToken = 158; // OK-токен static const int webAppInitData = 160; // Данные WebApp @@ -332,6 +336,8 @@ abstract class Opcode { notifProfile: 'NOTIF_PROFILE', notifBanners: 'NOTIF_BANNERS', notifFolders: 'NOTIF_FOLDERS', + audioTranscription: 'AUDIO_TRANSCRIPTION', + transcriptionResult: 'TRANSCRIPTION_RESULT', okToken: 'OK_TOKEN', webAppInitData: 'WEB_APP_INIT_DATA', complain: 'COMPLAIN', diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index a7ff78e..a97b3c8 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -53,8 +53,12 @@ class PacketDispatcher { if (packet.cmd == CmdType.ok || packet.cmd == CmdType.error || packet.cmd == CmdType.notFound) { + final payloadStr = packet.payload.toString(); + final displayPayload = packet.opcode == Opcode.login && payloadStr.length > 50 + ? '${payloadStr.substring(0, 50)}...' + : payloadStr; logger.i( - '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}', + '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: $displayPayload}', ); final completer = _pendingRequests.remove(packet.seq); diff --git a/lib/frontend/widgets/custom_notification.dart b/lib/frontend/widgets/custom_notification.dart index 5bdb22b..f30fcec 100644 --- a/lib/frontend/widgets/custom_notification.dart +++ b/lib/frontend/widgets/custom_notification.dart @@ -10,7 +10,7 @@ void showCustomNotificationOnOverlay(OverlayState overlay, String message) { builder: (context) => CustomNotification(message: message), ); overlay.insert(entry); - Future.delayed(const Duration(milliseconds: 1900), () { + Future.delayed(const Duration(milliseconds: 2600), () { entry.remove(); }); } @@ -38,7 +38,7 @@ class _CustomNotificationState extends State ); _opacity = Tween(begin: 0.0, end: 1.0).animate(_controller); _controller.forward(); - Future.delayed(const Duration(milliseconds: 1600), () { + Future.delayed(const Duration(milliseconds: 2300), () { if (mounted) _controller.reverse(); }); } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 03fc940..9e61d51 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -112,19 +112,22 @@ class MessageBubble extends StatelessWidget { return MessageType.text; } - // скругление уже смешариков т.е сообщений, те которые isme ? .. Это наши, после : это чужие BorderRadius get _borderRadius { final topRadius = Radius.circular(bubbleBorderRadius); - final bottomRadius = Radius.circular(bubbleBorderRadius); final smallRadius = const Radius.circular(4); + final cornerTL = isMe ? smallRadius : topRadius; + final cornerTR = isMe ? topRadius : smallRadius; + final cornerBL = isMe ? smallRadius : topRadius; + final cornerBR = isMe ? topRadius : smallRadius; + if (_hasPhotoWithCaption && (shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle || shape == BubbleShape.singleBottom)) { return BorderRadius.only( topLeft: topRadius, - topRight: isMe ? topRadius : topRadius, + topRight: topRadius, bottomLeft: smallRadius, bottomRight: smallRadius, ); @@ -136,16 +139,16 @@ class MessageBubble extends StatelessWidget { return BorderRadius.only( topLeft: smallRadius, topRight: smallRadius, - bottomLeft: isMe ? smallRadius : smallRadius, - bottomRight: isMe ? smallRadius : bottomRadius, + bottomLeft: smallRadius, + bottomRight: isMe ? smallRadius : topRadius, ); } switch (shape) { case BubbleShape.singleTop: return BorderRadius.only( - topLeft: isMe ? topRadius : smallRadius, - topRight: isMe ? smallRadius : topRadius, + topLeft: cornerTL, + topRight: cornerTR, bottomLeft: smallRadius, bottomRight: smallRadius, ); @@ -153,22 +156,22 @@ class MessageBubble extends StatelessWidget { return BorderRadius.only( topLeft: smallRadius, topRight: smallRadius, - bottomLeft: isMe ? topRadius : smallRadius, - bottomRight: isMe ? smallRadius : topRadius, + bottomLeft: cornerBL, + bottomRight: cornerBR, ); case BubbleShape.singleMiddle: return BorderRadius.only( - topLeft: topRadius, - topRight: topRadius, - bottomLeft: isMe ? topRadius : smallRadius, - bottomRight: isMe ? smallRadius : topRadius, + topLeft: cornerTL, + topRight: cornerTR, + bottomLeft: cornerBL, + bottomRight: cornerBR, ); case BubbleShape.groupedMiddle: return BorderRadius.only( - topLeft: isMe ? topRadius : smallRadius, - topRight: isMe ? smallRadius : smallRadius, - bottomLeft: isMe ? topRadius : smallRadius, - bottomRight: isMe ? smallRadius : smallRadius, + topLeft: cornerTL, + topRight: smallRadius, + bottomLeft: cornerBL, + bottomRight: smallRadius, ); } } @@ -273,13 +276,13 @@ class MessageBubble extends StatelessWidget { case MessageType.voice: switch (shape) { case BubbleShape.groupedMiddle: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); + return const EdgeInsets.symmetric(horizontal: 14, vertical: 4); case BubbleShape.singleTop: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); case BubbleShape.singleBottom: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); case BubbleShape.singleMiddle: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + return const EdgeInsets.symmetric(horizontal: 14, vertical: 4); } } return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); @@ -1550,23 +1553,47 @@ class MessageBubble extends StatelessWidget { final textColor = isMe ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); - final payload = message.payload; - final voice = payload?['voice'] as Map?; - final duration = voice?['duration'] as int? ?? 0; - final url = voice?['url']?.toString() ?? ''; - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - _VoiceMessageBubble( - duration: duration, - url: url, - textColor: textColor, - isMe: isMe, - ), - const SizedBox(height: 6), - _buildMeta(context), - ], + int duration = 0; + String url = ''; + String? waveData; + int? audioId; + + final attaches = message.attachments; + if (attaches != null && attaches.isNotEmpty) { + for (final a in attaches) { + if (a is AudioAttachment) { + duration = ((a.duration ?? 0) / 1000).round(); + url = a.fileUrl ?? a.baseUrl ?? ''; + waveData = a.waveform; + audioId = a.audioId; + break; + } + } + } + + if (duration == 0 && url.isEmpty) { + final payload = message.payload; + final voice = payload?['voice'] as Map?; + duration = ((voice?['duration'] as int? ?? 0) / 1000).round(); + url = voice?['url']?.toString() ?? ''; + } + + final cachedTranscription = TranscriptionCache.get(message.id); + + return _VoiceMessageBubble( + duration: duration, + url: url, + textColor: textColor, + isMe: isMe, + status: message.status, + time: message.time, + cs: cs, + waveData: waveData, + chatId: message.chatId, + messageId: message.id, + audioId: audioId, + preloadedText: cachedTranscription?.text, ); } @@ -1666,12 +1693,28 @@ class _VoiceMessageBubble extends StatefulWidget { final String url; final Color textColor; final bool isMe; + final String? status; + final int time; + final ColorScheme cs; + final String? waveData; + final int chatId; + final String messageId; + final int? audioId; + final String? preloadedText; const _VoiceMessageBubble({ required this.duration, required this.url, required this.textColor, required this.isMe, + this.status, + required this.time, + required this.cs, + this.waveData, + required this.chatId, + required this.messageId, + this.audioId, + this.preloadedText, }); @override @@ -1681,97 +1724,305 @@ class _VoiceMessageBubble extends StatefulWidget { class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { bool _isPlaying = false; double _progress = 0.0; + bool _transcriptionVisible = false; + String? _transcriptionText; + bool _transcriptionLoading = false; @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final isDark = cs.brightness == Brightness.dark; + void initState() { + super.initState(); + if (widget.preloadedText != null) { + _transcriptionText = widget.preloadedText; + _transcriptionVisible = true; + } + } - return Container( - width: 220, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( + String _formatDuration(int seconds) { + final min = seconds ~/ 60; + final sec = seconds % 60; + return '$min:${sec.toString().padLeft(2, '0')}'; + } + + String _formatTime(int timestamp) { + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + Widget _buildStatusIcon() { + final status = widget.status; + IconData icon; + Color color; + + if (status == null || status == 'sending' || status == 'pending') { + icon = Symbols.check; + color = Colors.white54; + } else { + switch (status) { + case 'sent': + icon = Symbols.check; + color = Colors.white54; + case 'delivered': + icon = Symbols.done_all; + color = Colors.white54; + case 'read': + icon = Symbols.done_all; + color = const Color(0xFF34C759); + case 'error': + icon = Symbols.error; + color = Colors.redAccent; + default: + icon = Symbols.check; + color = Colors.white54; + } + } + + return Icon(icon, size: 14, color: color); + } + + Widget build(BuildContext context) { + final isDark = widget.cs.brightness == Brightness.dark; + final waveInactiveColor = widget.isMe + ? Colors.white.withValues(alpha: 0.35) + : (isDark + ? widget.cs.surfaceContainerHighest + : const Color(0xFFD1D1D6)); + final waveActiveColor = widget.isMe + ? Colors.white.withValues(alpha: 0.7) + : widget.cs.primary; + + return SizedBox( + width: 240, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - onTap: _togglePlay, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: widget.isMe - ? Colors.white.withValues(alpha: 0.2) - : cs.primaryContainer, - shape: BoxShape.circle, + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + GestureDetector( + onTap: _togglePlay, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: widget.isMe + ? Colors.white.withValues(alpha: 0.2) + : widget.cs.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + _isPlaying ? Symbols.pause : Symbols.play_arrow, + color: widget.isMe ? Colors.white : widget.cs.primary, + size: 18, + ), + ), ), - child: Icon( - _isPlaying ? Symbols.pause : Symbols.play_arrow, - color: widget.isMe ? Colors.white : cs.primary, - size: 20, - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - children: [ - Container( - height: 24, - decoration: BoxDecoration( - color: widget.isMe - ? Colors.white.withValues(alpha: 0.2) - : (isDark - ? cs.surfaceContainerHighest - : const Color(0xFFD1D1D6)), - borderRadius: BorderRadius.circular(2), - ), - ), - FractionallySizedBox( - widthFactor: _progress.clamp(0.0, 1.0), + const SizedBox(width: 10), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return GestureDetector( + onTapDown: (details) { + setState(() { + _progress = (details.localPosition.dx / + constraints.maxWidth) + .clamp(0.0, 1.0); + }); + }, + onHorizontalDragUpdate: (details) { + setState(() { + _progress = (details.localPosition.dx / + constraints.maxWidth) + .clamp(0.0, 1.0); + }); + }, child: Container( - height: 24, + height: 4, decoration: BoxDecoration( - color: widget.isMe - ? Colors.white.withValues(alpha: 0.5) - : cs.primary, + color: waveInactiveColor, borderRadius: BorderRadius.circular(2), ), - ), - ), - SizedBox( - height: 24, - child: Center( - child: Text( - _formatDuration(widget.duration), - style: TextStyle( - color: widget.textColor.withValues(alpha: 0.8), - fontSize: 12, - fontWeight: FontWeight.w500, + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: _progress.clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: waveActiveColor, + borderRadius: BorderRadius.circular(2), + ), ), ), ), - ), - ], + ); + }, ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: _requestTranscription, + child: SizedBox( + width: 20, + height: 32, + child: Center( + child: _transcriptionLoading + ? SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: widget.textColor.withValues(alpha: 0.6), + ), + ) + : Text( + 'Т', + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.6), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _formatDuration(widget.duration), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.7), + fontSize: 11, + ), + ), + const SizedBox(width: 8), + Expanded( + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + alignment: Alignment.topLeft, + child: _transcriptionVisible + ? Text( + _transcriptionText ?? '', + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.8), + fontSize: 12, + height: 1.3, + ), + maxLines: 10, + overflow: TextOverflow.ellipsis, + ) + : const SizedBox.shrink(), + ), + ), + if (!_transcriptionVisible) ...[ + Text( + _formatTime(widget.time), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.6), + fontSize: 10, + ), + ), + if (widget.isMe) ...[ + const SizedBox(width: 2), + _buildStatusIcon(), + ], + ], + ], + ), + if (_transcriptionVisible) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + _formatTime(widget.time), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.6), + fontSize: 10, + ), + ), + if (widget.isMe) ...[ + const SizedBox(width: 2), + _buildStatusIcon(), + ], ], ), - ), + ], ], ), ); } + Widget _buildProgressBar(Color inactive, Color active) { + return Container( + height: 4, + decoration: BoxDecoration( + color: inactive, + borderRadius: BorderRadius.circular(2), + ), + ); + } + void _togglePlay() { setState(() { _isPlaying = !_isPlaying; }); } - String _formatDuration(int seconds) { - final min = seconds ~/ 60; - final sec = seconds % 60; - return '$min:${sec.toString().padLeft(2, '0')}'; + Future _requestTranscription() async { + if (widget.audioId == null) return; + + if (_transcriptionVisible && _transcriptionText != null) { + setState(() { + _transcriptionVisible = false; + }); + return; + } + + if (TranscriptionCache.has(widget.messageId)) { + final cached = TranscriptionCache.get(widget.messageId)!; + setState(() { + _transcriptionText = cached.text ?? 'не удалось распознать текст'; + _transcriptionVisible = true; + }); + return; + } + + setState(() { + _transcriptionLoading = true; + }); + + try { + final result = await messagesModule.requestTranscription( + widget.chatId, + int.tryParse(widget.messageId) ?? 0, + widget.audioId!, + ); + + TranscriptionCache.put(widget.messageId, result); + + setState(() { + _transcriptionLoading = false; + if (result.status == 1) { + _transcriptionText = (result.text == null || result.text!.isEmpty) + ? 'не удалось распознать текст' + : result.text; + _transcriptionVisible = true; + } else if (result.status == 0) { + _transcriptionText = 'транскрибация...'; + _transcriptionVisible = true; + } + }); + } catch (e) { + setState(() { + _transcriptionLoading = false; + _transcriptionText = 'ошибка транскрибации'; + _transcriptionVisible = true; + }); + } } } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index fd7d35f..8e137d2 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -198,14 +198,27 @@ class AudioAttachment extends MessageAttachment { } catch (_) {} } + String? waveStr; + final waveRaw = map['wave']; + if (waveRaw is String) { + waveStr = waveRaw; + } else if (waveRaw is List) { + try { + final bytes = List.from(waveRaw); + final base64 = String.fromCharCodes(bytes); + waveStr = 'data:image/webp;base64,$base64'; + } catch (_) {} + } + return AudioAttachment( previewData: previewStr, - baseUrl: map['baseUrl'] as String?, + baseUrl: map['baseUrl']?.toString(), + fileUrl: map['url']?.toString(), audioId: map['audioId'] as int?, - audioToken: map['audioToken'] as String?, + audioToken: map['token']?.toString(), duration: map['duration'] as int?, size: map['size'] as int?, - waveform: map['waveform'] as String?, + waveform: waveStr, ); } From d541e9df9dcf52ceed7563a59ffd40102285aade Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 14 May 2026 13:12:41 +0300 Subject: [PATCH 11/43] =?UTF-8?q?fix(transport):=20Zstd-=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BF=D0=B0=D0=BA=D0=BE=D0=B2=D0=BA=D0=B0,=20=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D0=B0=20PacketReceiver,=20fix=20=D0=B8=D0=BC=D1=91?= =?UTF-8?q?=D0=BD=20=D0=B8=20=D1=81=D1=82=D0=B8=D0=BA=D0=B5=D1=80=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 10 ++++- lib/backend/modules/chats.dart | 1 - lib/backend/modules/contacts.dart | 54 ++++++++++++++++++++--- lib/core/protocol/packet.dart | 55 +++++++++++++++++------- lib/core/transport/receiver.dart | 21 ++++----- lib/frontend/widgets/message_bubble.dart | 8 ++-- lib/main.dart | 5 +++ lib/models/attachment.dart | 6 +-- pubspec.lock | 16 +++++++ pubspec.yaml | 1 + 10 files changed, 137 insertions(+), 40 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 495b56a..1fdd71f 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -266,7 +266,15 @@ class Api { } Future _onDataReceived(Uint8List data) async { - await for (final packet in _receiver.feed(data)) { + final rawPackets = _receiver.feed(data); + for (final raw in rawPackets) { + final Packet packet; + try { + packet = await unpackPacket(raw); + } catch (e) { + logger.e('PacketReceiver: ошибка распаковки: $e'); + continue; + } if (packet.isError && packet.payload is Map && (packet.payload['message'] == 'FAIL_LOGIN_TOKEN' || diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index f47aa70..2308163 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -160,7 +160,6 @@ class ChatsModule { static Future> getChats(int accountId) async { try { final rows = await AppDatabase.loadChats(accountId); - return rows.map(CachedChat.fromDbRow).toList(); } catch (e) { logger.e("Ошибка при получении чатов: $e"); diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index 71e4926..b4f103b 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -1,4 +1,5 @@ import '../../core/storage/app_database.dart'; +import 'messages.dart'; class CachedContact { final int id; @@ -44,22 +45,65 @@ class ContactsModule { final contacts = data['contacts']; if (contacts is! List || contacts.isEmpty) return; - final rows = contacts - .whereType() - .map((c) => _parseContact(c.cast(), accountId)) - .whereType>() - .toList(); + final rows = >[]; + for (final raw in contacts.whereType()) { + final contact = raw.cast(); + final row = _parseContact(contact, accountId); + if (row != null) rows.add(row); + _primeContactCache(contact); + } if (rows.isNotEmpty) { await AppDatabase.saveContacts(rows); } } + static void _primeContactCache(Map contact) { + final id = contact['id']; + if (id is! int) return; + + final names = contact['names']; + if (names is List && names.isNotEmpty) { + final nameRaw = names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.firstWhere((n) => n is Map, orElse: () => null), + ); + if (nameRaw is Map) { + final firstName = (nameRaw['firstName'] as String?) ?? ''; + final lastName = nameRaw['lastName'] as String?; + final fullName = (lastName != null && lastName.isNotEmpty) + ? '$firstName $lastName' + : firstName; + if (fullName.isNotEmpty) ContactCache.put(id, fullName); + } + } + + final baseUrl = contact['baseUrl'] as String?; + if (baseUrl != null && baseUrl.isNotEmpty) { + ContactCache.putAvatar(id, baseUrl); + } + } + static Future> getContacts(int accountId) async { final rows = await AppDatabase.loadContacts(accountId); return rows.map(CachedContact.fromDbRow).toList(); } + /// Прогревает in-memory ContactCache из локальных контактов. + /// Нужно вызывать на cold start: иначе кэш пуст до следующего логина. + static Future primeCacheFromDb(int accountId) async { + final contacts = await getContacts(accountId); + for (final c in contacts) { + final fullName = (c.lastName != null && c.lastName!.isNotEmpty) + ? '${c.firstName} ${c.lastName}' + : c.firstName; + if (fullName.isNotEmpty) ContactCache.put(c.id, fullName); + if (c.baseUrl != null && c.baseUrl!.isNotEmpty) { + ContactCache.putAvatar(c.id, c.baseUrl); + } + } + } + static Map? _parseContact( Map contact, int accountId, diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 5637328..e8b3e27 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'dart:isolate'; import 'package:dart_lz4/dart_lz4.dart'; +import 'package:es_compression/zstd.dart'; import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; /// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10 @@ -129,21 +130,7 @@ Future unpackPacket(Uint8List packet) async { if (payloadBytes.isNotEmpty) { if (compFlag != 0) { - try { - payloadBytes = lz4Decompress( - payloadBytes, - decompressedSize: _maxDecompressedSize, - ); - } catch (_) { - try { - payloadBytes = _lz4BlockDecompress( - payloadBytes, - _maxDecompressedSize, - ); - } catch (e) { - throw Exception("LZ4 decompression error: $e"); - } - } + payloadBytes = _decompressPayload(payloadBytes); } try { @@ -165,6 +152,44 @@ Future unpackPacket(Uint8List packet) async { }); } +/// Определяет формат сжатия по magic-number и распаковывает payload. +/// Сервер может присылать LZ4 block ИЛИ Zstandard в зависимости от ответа. +Uint8List _decompressPayload(Uint8List src) { + // Zstandard: magic 28 B5 2F FD (little-endian) + if (src.length >= 4 && + src[0] == 0x28 && + src[1] == 0xB5 && + src[2] == 0x2F && + src[3] == 0xFD) { + try { + final out = zstd.decode(src); + return out is Uint8List ? out : Uint8List.fromList(out); + } catch (e) { + throw Exception('Zstd decompression error: $e'); + } + } + + // LZ4 frame: magic 04 22 4D 18 + if (src.length >= 4 && + src[0] == 0x04 && + src[1] == 0x22 && + src[2] == 0x4D && + src[3] == 0x18) { + try { + return lz4Decompress(src, decompressedSize: _maxDecompressedSize); + } catch (e) { + throw Exception('LZ4 frame decompression error: $e'); + } + } + + // По умолчанию — LZ4 block (без magic) + try { + return _lz4BlockDecompress(src, _maxDecompressedSize); + } catch (e) { + throw Exception('LZ4 block decompression error: $e'); + } +} + /// LZ4 block декомпрессия (без frame-заголовка). /// Сервер шлёт именно block-формат, dart_lz4 его не поддерживает. Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) { diff --git a/lib/core/transport/receiver.dart b/lib/core/transport/receiver.dart index a14476e..e0a45ee 100644 --- a/lib/core/transport/receiver.dart +++ b/lib/core/transport/receiver.dart @@ -4,15 +4,16 @@ import '../protocol/packet.dart'; import '../utils/logger.dart'; /// Буфер входящих данных. -/// Копит сырые байты из сокета, собирает из них целые пакеты. +/// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов. class PacketReceiver { Uint8List _buffer = Uint8List(0); static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта - /// Добавляет байты в буфер, возвращает поток собранных пакетов. - /// Неполные данные остаются в буфере до следующего вызова. - Stream feed(Uint8List data) async* { + /// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы. + /// Полностью синхронный — нарезка не блокируется на распаковке, поэтому + /// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`. + List feed(Uint8List data) { final newBuffer = Uint8List(_buffer.length + data.length); newBuffer.setAll(0, _buffer); newBuffer.setAll(_buffer.length, data); @@ -23,9 +24,10 @@ class PacketReceiver { 'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс', ); reset(); - return; + return const []; } + final packets = []; while (_buffer.length >= headerSize) { final bd = ByteData.view( _buffer.buffer, @@ -38,15 +40,10 @@ class PacketReceiver { if (_buffer.length < totalLength) break; - final packetBytes = Uint8List.sublistView(_buffer, 0, totalLength); + packets.add(Uint8List.sublistView(_buffer, 0, totalLength)); _buffer = _buffer.sublist(totalLength); - - try { - yield await unpackPacket(packetBytes); - } catch (e) { - logger.e('PacketReceiver: ошибка распаковки: $e'); - } } + return packets; } void reset() { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 1217554..db9b7cf 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1270,15 +1270,17 @@ class MessageBubble extends StatelessWidget { } Widget _buildStickerAttachment(BuildContext ctx, MessageAttachment sticker) { - final preview = (sticker as dynamic).previewData as String? ?? ''; + final url = sticker.baseUrl ?? ''; + final preview = sticker.previewData ?? ''; + final imageUrl = url.isNotEmpty ? url : preview; return ClipRRect( borderRadius: BorderRadius.circular(photoBorderRadius), child: Stack( children: [ - if (preview.isNotEmpty) + if (imageUrl.isNotEmpty) Image.network( - preview, + imageUrl, width: 150, height: 150, fit: BoxFit.contain, diff --git a/lib/main.dart b/lib/main.dart index 5c62627..1654efd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:komet/l10n/app_localizations.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'backend/api.dart'; import 'backend/modules/account.dart'; +import 'backend/modules/contacts.dart'; import 'backend/modules/messages.dart'; import 'core/storage/app_database.dart'; import 'core/storage/token_storage.dart'; @@ -36,6 +37,10 @@ Future _loadInitialLocale() async { void main() async { WidgetsFlutterBinding.ensureInitialized(); await AppDatabase.init(); + final activeAccountId = await TokenStorage.getActiveAccountId(); + if (activeAccountId != null) { + await ContactsModule.primeCacheFromDb(activeAccountId); + } await api.connect(); final initialLocale = await _loadInitialLocale(); diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 825e348..4d72a09 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -304,9 +304,9 @@ class StickerAttachment extends MessageAttachment { return StickerAttachment( previewData: previewStr, - baseUrl: map['baseUrl'] as String?, - stickerId: map['stickerId'] as String?, - stickerPackId: map['stickerPackId'] as String?, + baseUrl: (map['url'] ?? map['baseUrl'])?.toString(), + stickerId: map['stickerId']?.toString(), + stickerPackId: (map['stickerPackId'] ?? map['setId'])?.toString(), width: map['width'] as int?, height: map['height'] as int?, ); diff --git a/pubspec.lock b/pubspec.lock index 1f7d572..e834ec0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -105,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.8" + es_compression: + dependency: "direct main" + description: + name: es_compression + sha256: c1ff7af54802631cf5c3942cb67bb99daadcc087f573ca99a9de91002d1a7ece + url: "https://pub.dev" + source: hosted + version: "2.0.15" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f681c96..017572f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 dart_lz4: ^1.0.0 + es_compression: ^2.0.15 msgpack_dart: ^1.0.1 logger: ^2.6.2 device_info_plus: 12.3.0 From 358b82a12c02f7c4fbffe77f0f72b4429960c44f Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 14 May 2026 17:53:27 +0700 Subject: [PATCH 12/43] =?UTF-8?q?=D1=87=D0=B5=D1=82=20=D0=BF=D0=BE=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D0=B8=D0=BB=20+=20=D0=BE=D1=82=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B0=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BD=D0=B5=D0=B7=D0=B0=D0=BA=D0=BE=D0=BD=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 80 ++++ lib/frontend/screens/chats/chat_screen.dart | 61 ++- lib/frontend/widgets/attachment_panel.dart | 390 ++++++++++++++++++++ lib/frontend/widgets/message_bubble.dart | 4 +- pubspec.lock | 28 +- pubspec.yaml | 3 + 6 files changed, 549 insertions(+), 17 deletions(-) create mode 100644 lib/frontend/widgets/attachment_panel.dart diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 150e5ca..51af04e 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -51,6 +51,45 @@ class TranscriptionCache { static bool has(String messageId) => _cache.containsKey(messageId); } +class FileHistoryEntry { + final int fileId; + final String? url; + final String? token; + final DateTime sentAt; + + FileHistoryEntry({ + required this.fileId, + this.url, + this.token, + required this.sentAt, + }); +} + +class FileHistoryCache { + static final List _history = []; + + static List get history => List.unmodifiable(_history); + + static void add(FileHistoryEntry entry) { + _history.insert(0, entry); + if (_history.length > 50) _history.removeLast(); + } + + static bool get isEmpty => _history.isEmpty; +} + +class FileUploadInfo { + final String url; + final int fileId; + final String token; + + FileUploadInfo({ + required this.url, + required this.fileId, + required this.token, + }); +} + class CachedMessage { final String id; final int accountId; @@ -304,6 +343,47 @@ class MessagesModule { return TranscriptionResult(status: transcriptionStatus); } + Future requestUploadUrl({int count = 1}) async { + final payload = {'count': count}; + final response = await _api.sendRequest(Opcode.fileUpload, payload); + if (!response.isOk) return null; + + final data = response.payload; + if (data is! Map) return null; + + final infoList = data['info'] as List?; + if (infoList == null || infoList.isEmpty) return null; + + final info = infoList.first; + if (info is! Map) return null; + + return FileUploadInfo( + url: info['url'] as String? ?? '', + fileId: info['fileId'] as int? ?? 0, + token: info['token'] as String? ?? '', + ); + } + + Future sendFileMessage( + int chatId, + int fileId, { + bool notify = true, + }) async { + final payload = { + 'chatId': chatId, + 'message': { + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'attaches': [ + {'_type': 'FILE', 'fileId': fileId} + ], + }, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + return response.isOk; + } + Future downloadPhoto(String baseUrl, String photoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 13d8b3d..9d50c57 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -11,6 +11,7 @@ import '../../../core/storage/app_database.dart'; import '../../../models/attachment.dart'; import '../../../backend/modules/messages.dart' show ContactCache; import '../../widgets/message_bubble.dart'; +import '../../widgets/attachment_panel.dart'; class ChatScreen extends StatefulWidget { final int chatId; @@ -37,6 +38,7 @@ class _ChatScreenState extends State bool _hasText = false; bool _isLoading = true; bool _isSending = false; + bool _showAttachmentPanel = false; late AnimationController _shimmerController; List _messages = []; int _myId = 0; @@ -336,14 +338,28 @@ class _ChatScreenState extends State ], ), )), - body: Column( + body: Stack( children: [ - Expanded( - child: _isLoading && _messages.isEmpty - ? _buildShimmerLoading() - : _buildMessagesList(), + Column( + children: [ + Expanded( + child: _isLoading && _messages.isEmpty + ? _buildShimmerLoading() + : _buildMessagesList(), + ), + _buildInputArea(context), + ], ), - _buildInputArea(context), + if (_showAttachmentPanel) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: AttachmentPanel( + chatId: widget.chatId, + onClose: () => setState(() => _showAttachmentPanel = false), + ), + ), ], ), ); @@ -582,13 +598,32 @@ class _ChatScreenState extends State opacity: _hasText ? 0 : 1, child: _hasText ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.only(left: 12), - child: Icon( - Symbols.attachment, - color: mutedIcon, - size: 24, - weight: 400, + : GestureDetector( + onTap: _showAttachmentPanel ? null : () => setState(() => _showAttachmentPanel = true), + child: Padding( + padding: const EdgeInsets.only(left: 12), + child: Stack( + alignment: Alignment.center, + children: [ + if (_showAttachmentPanel) + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.primary, + ), + ), + Icon( + Symbols.attachment, + color: _showAttachmentPanel + ? cs.onSurfaceVariant.withValues(alpha: 0.3) + : mutedIcon, + size: 24, + weight: 400, + ), + ], + ), ), ), ), diff --git a/lib/frontend/widgets/attachment_panel.dart b/lib/frontend/widgets/attachment_panel.dart new file mode 100644 index 0000000..d749691 --- /dev/null +++ b/lib/frontend/widgets/attachment_panel.dart @@ -0,0 +1,390 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:komet/backend/modules/messages.dart' show FileHistoryCache, FileHistoryEntry; +import 'package:komet/core/config/proxy_config.dart'; +import 'package:komet/core/protocol/opcode_map.dart'; +import 'package:komet/core/protocol/packet.dart'; +import 'package:komet/core/transport/proxy_connector.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; +import 'package:komet/main.dart' show api, messagesModule; +import 'package:material_symbols_icons/symbols.dart'; + +class AttachmentPanel extends StatefulWidget { + final int chatId; + final VoidCallback onClose; + + const AttachmentPanel({ + super.key, + required this.chatId, + required this.onClose, + }); + + @override + State createState() => _AttachmentPanelState(); +} + +class _AttachmentPanelState extends State { + final TextEditingController _fileIdController = TextEditingController(); + bool _isUploading = false; + + Future _pickAndUploadFile() async { + final result = await FilePicker.platform.pickFiles(); + if (result == null || result.files.isEmpty) return; + final file = result.files.first; + if (file.path == null) return; + + setState(() => _isUploading = true); + + try { + final uploadInfo = await messagesModule.requestUploadUrl(); + if (uploadInfo == null) { + if (mounted) showCustomNotification(context, 'Не удалось получить ссылку'); + return; + } + + final completer = Completer(); + void Function(Packet)? handler; + handler = (Packet packet) { + final payload = packet.payload; + if (payload is Map && payload['fileId'] == uploadInfo.fileId) { + api.unregisterPushHandler(Opcode.notifAttach); + completer.complete(); + } + }; + api.registerPushHandler(Opcode.notifAttach, (Packet p) => handler!(p)); + + await api.sendRequest(Opcode.msgTyping, { + 'chatId': widget.chatId, + 'type': 'FILE', + }); + + final uri = Uri.parse(uploadInfo.url); + final fileBytes = await File(file.path!).readAsBytes(); + final proxySettings = await ProxyConfig.load(); + + int statusCode; + if (proxySettings.isEnabled) { + final connector = ProxyConnector(proxySettings); + final proxySocket = await connector.connect(uri.host, uri.port); + final socket = uri.scheme == 'https' + ? await RawSecureSocket.secure( + proxySocket, + host: uri.host, + onBadCertificate: (_) => true, + ) + : proxySocket; + statusCode = await _rawPut(socket, uri, fileBytes); + } else { + final socket = await RawSocket.connect(uri.host, uri.port); + final secureSocket = uri.scheme == 'https' + ? await RawSecureSocket.secure( + socket, + host: uri.host, + onBadCertificate: (_) => true, + ) + : socket; + statusCode = await _rawPut(secureSocket, uri, fileBytes); + } + + if (statusCode == 200 || statusCode == 204) { + await completer.future.timeout( + const Duration(seconds: 30), + onTimeout: () { + api.unregisterPushHandler(Opcode.notifAttach); + throw TimeoutException('Тайм-аут подтверждения загрузки'); + }, + ); + + final sent = await messagesModule.sendFileMessage(widget.chatId, uploadInfo.fileId); + if (sent) { + FileHistoryCache.add(FileHistoryEntry( + fileId: uploadInfo.fileId, + url: uploadInfo.url, + token: uploadInfo.token, + sentAt: DateTime.now(), + )); + if (mounted) { + showCustomNotification(context, 'Файл отправлен'); + widget.onClose(); + } + } else { + if (mounted) showCustomNotification(context, 'Ошибка отправки сообщения'); + } + } else { + api.unregisterPushHandler(Opcode.notifAttach); + if (mounted) showCustomNotification(context, 'Ошибка загрузки: $statusCode'); + } + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + } finally { + if (mounted) setState(() => _isUploading = false); + } + } + + Future _rawPut(RawSocket socket, Uri uri, List body) async { + final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}'; + final host = uri.host; + + // Try HttpClient-based approach first, fall back to raw socket + final httpClient = HttpClient(); + httpClient.badCertificateCallback = (cert, host, port) => true; + + try { + final request = await httpClient.putUrl(uri); + request.headers.contentType = ContentType('application', 'octet-stream'); + request.add(body is Uint8List ? body : Uint8List.fromList(body)); + final response = await request.close().timeout(const Duration(minutes: 5)); + final statusCode = response.statusCode; + await response.drain(); + debugPrint('HTTP Response via HttpClient: $statusCode'); + socket.close(); + return statusCode; + } catch (e) { + debugPrint('HttpClient failed, falling back to raw socket: $e'); + // Fallback to raw HTTP + final rawRequest = StringBuffer() + ..write('PUT $path HTTP/1.1\r\n') + ..write('Host: $host\r\n') + ..write('Content-Type: application/octet-stream\r\n') + ..write('Content-Length: ${body.length}\r\n') + ..write('Connection: close\r\n') + ..write('\r\n'); + + final requestBytes = utf8.encode(rawRequest.toString()); + final allBytes = [...requestBytes, ...(body is Uint8List ? body : Uint8List.fromList(body))]; + socket.write(Uint8List.fromList(allBytes)); + + final responseBytes = []; + final completer = Completer(); + Timer? timer; + + socket.listen((event) { + if (event == RawSocketEvent.read) { + final data = socket.read(); + if (data != null) responseBytes.addAll(data); + } else if (event == RawSocketEvent.readClosed || event == RawSocketEvent.closed) { + timer?.cancel(); + if (responseBytes.isEmpty) { + completer.completeError(const SocketException('Пустой ответ сервера')); + return; + } + final headerEnd = _findHeaderEnd(responseBytes); + if (headerEnd == -1) { + completer.completeError(const SocketException('Не удалось прочитать заголовок ответа')); + return; + } + final headerStr = utf8.decode(responseBytes.sublist(0, headerEnd), allowMalformed: true); + final statusLine = headerStr.split('\r\n').first; + debugPrint('HTTP Response (raw): $statusLine'); + final parts = statusLine.split(' '); + completer.complete(parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0); + } + }, onError: (e) { + timer?.cancel(); + completer.completeError(e); + }); + + timer = Timer(const Duration(minutes: 5), () { + socket.close(); + completer.completeError(TimeoutException('Тайм-аут загрузки')); + }); + + return completer.future; + } + } + + int _findHeaderEnd(List bytes) { + for (var i = 0; i < bytes.length - 3; i++) { + if (bytes[i] == 0x0D && bytes[i + 1] == 0x0A && + bytes[i + 2] == 0x0D && bytes[i + 3] == 0x0A) { + return i + 4; + } + } + return -1; + } + + Future _uploadByFileId() async { + final fileIdStr = _fileIdController.text.trim(); + if (fileIdStr.isEmpty) return; + final fileId = int.tryParse(fileIdStr); + if (fileId == null) { + if (mounted) showCustomNotification(context, 'Неверный fileId'); + return; + } + setState(() => _isUploading = true); + try { + final sent = await messagesModule.sendFileMessage(widget.chatId, fileId); + if (sent) { + if (mounted) { + showCustomNotification(context, 'Файл отправлен'); + widget.onClose(); + } + } else { + if (mounted) showCustomNotification(context, 'Ошибка отправки'); + } + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + } finally { + if (mounted) setState(() => _isUploading = false); + } + } + + @override + void dispose() { + _fileIdController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GestureDetector( + onVerticalDragEnd: (details) { + if (details.velocity.pixelsPerSecond.dy > 300) widget.onClose(); + }, + child: Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)), + ), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(top: 8), + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row(children: [ + Expanded(child: _buildButton( + label: 'Выбрать из файла', + icon: Symbols.folder_open, + filled: true, + onTap: _isUploading ? null : _pickAndUploadFile, + cs: cs, + )), + const SizedBox(width: 8), + Expanded(child: _buildButton( + label: 'Отправить по id', + icon: null, + filled: false, + onTap: _isUploading ? null : _uploadByFileId, + cs: cs, + )), + ]), + ), + if (_isUploading) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: LinearProgressIndicator(), + ) + else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _fileIdController, + style: TextStyle(color: cs.onSurface, fontSize: 14), + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'fileId...', + hintStyle: TextStyle(color: cs.onSurfaceVariant), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + ), + ), + ), + const Divider(height: 16), + Padding( + padding: const EdgeInsets.only(left: 16, bottom: 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text('История', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w500)), + ), + ), + if (FileHistoryCache.isEmpty) + Padding( + padding: const EdgeInsets.all(24), + child: Text('история пуста...', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14)), + ) + else + SizedBox( + height: 100, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: FileHistoryCache.history.length, + itemBuilder: (ctx, idx) { + final e = FileHistoryCache.history[idx]; + return Container( + width: 72, + margin: const EdgeInsets.only(right: 8, bottom: 8), + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)), + ), + child: Center(child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.description, color: cs.onSurfaceVariant, size: 28), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text('${e.fileId}', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 9), overflow: TextOverflow.ellipsis, textAlign: TextAlign.center), + ), + ], + )), + ); + }, + ), + ), + const SizedBox(height: 8), + ]), + ), + ); + } + + Widget _buildButton({ + required String label, + required IconData? icon, + required bool filled, + required VoidCallback? onTap, + required ColorScheme cs, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: filled ? cs.primaryContainer : cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + border: filled ? null : Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon, size: 18, color: filled ? cs.onPrimaryContainer : cs.onSurface), + const SizedBox(width: 6), + ], + Text(label, style: TextStyle( + color: filled ? cs.onPrimaryContainer : cs.onSurface, + fontWeight: FontWeight.w500, + fontSize: 13, + )), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 69ec7f2..949ae9f 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -168,9 +168,9 @@ class MessageBubble extends StatelessWidget { ); case BubbleShape.groupedMiddle: return BorderRadius.only( - topLeft: cornerTL, + topLeft: isMe ? cornerTL : smallRadius, topRight: smallRadius, - bottomLeft: cornerBL, + bottomLeft: isMe ? cornerBL : smallRadius, bottomRight: smallRadius, ); } diff --git a/pubspec.lock b/pubspec.lock index 1644efb..d510664 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -169,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" fixnum: dependency: transitive description: @@ -203,6 +219,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_secure_storage: dependency: "direct main" description: @@ -294,7 +318,7 @@ packages: source: hosted version: "1.0.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -302,7 +326,7 @@ packages: source: hosted version: "1.6.0" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" diff --git a/pubspec.yaml b/pubspec.yaml index 2e32aa1..cf9fd81 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,9 @@ dependencies: flutter_timezone: ^5.0.1 timezone: ^0.11.0 flutter_secure_storage: ^10.0.0 + http: ^1.4.0 + http_parser: ^4.1.0 + file_picker: ^8.0.0 sqflite: ^2.4.2 sqflite_common_ffi: ^2.4.0+2 path: ^1.9.1 From fe512fc96d9a28782f606124ce771bcadafac160 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 14 May 2026 18:10:05 +0700 Subject: [PATCH 13/43] =?UTF-8?q?=D0=B4=D0=B0=20=D1=85=D1=83=D0=BB=D0=B8?= =?UTF-8?q?=20=D0=BE=D0=BD=D0=BE=20=D0=BD=D0=B5=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/widgets/attachment_panel.dart | 116 +++++++++------------ 1 file changed, 51 insertions(+), 65 deletions(-) diff --git a/lib/frontend/widgets/attachment_panel.dart b/lib/frontend/widgets/attachment_panel.dart index d749691..f32a964 100644 --- a/lib/frontend/widgets/attachment_panel.dart +++ b/lib/frontend/widgets/attachment_panel.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:convert'; +import 'dart:convert' show utf8; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -77,7 +77,7 @@ class _AttachmentPanelState extends State { onBadCertificate: (_) => true, ) : proxySocket; - statusCode = await _rawPut(socket, uri, fileBytes); + statusCode = await _rawPost(socket, uri, fileBytes, file.name); } else { final socket = await RawSocket.connect(uri.host, uri.port); final secureSocket = uri.scheme == 'https' @@ -87,10 +87,10 @@ class _AttachmentPanelState extends State { onBadCertificate: (_) => true, ) : socket; - statusCode = await _rawPut(secureSocket, uri, fileBytes); + statusCode = await _rawPost(secureSocket, uri, fileBytes, file.name); } - if (statusCode == 200 || statusCode == 204) { + if (statusCode == 200) { await completer.future.timeout( const Duration(seconds: 30), onTimeout: () { @@ -125,76 +125,62 @@ class _AttachmentPanelState extends State { } } - Future _rawPut(RawSocket socket, Uri uri, List body) async { + Future _rawPost(RawSocket socket, Uri uri, List body, String filename) async { final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}'; final host = uri.host; + final total = body.length; - // Try HttpClient-based approach first, fall back to raw socket - final httpClient = HttpClient(); - httpClient.badCertificateCallback = (cert, host, port) => true; + final request = StringBuffer() + ..write('POST $path HTTP/1.1\r\n') + ..write('Host: $host\r\n') + ..write('Content-Type: application/x-binary; charset=x-user-defined\r\n') + ..write('Content-Disposition: attachment; filename=$filename\r\n') + ..write('Connection: keep-alive\r\n') + ..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n') + ..write('Content-Range: bytes 0-${total - 1}/$total\r\n') + ..write('Content-Length: $total\r\n') + ..write('\r\n'); - try { - final request = await httpClient.putUrl(uri); - request.headers.contentType = ContentType('application', 'octet-stream'); - request.add(body is Uint8List ? body : Uint8List.fromList(body)); - final response = await request.close().timeout(const Duration(minutes: 5)); - final statusCode = response.statusCode; - await response.drain(); - debugPrint('HTTP Response via HttpClient: $statusCode'); - socket.close(); - return statusCode; - } catch (e) { - debugPrint('HttpClient failed, falling back to raw socket: $e'); - // Fallback to raw HTTP - final rawRequest = StringBuffer() - ..write('PUT $path HTTP/1.1\r\n') - ..write('Host: $host\r\n') - ..write('Content-Type: application/octet-stream\r\n') - ..write('Content-Length: ${body.length}\r\n') - ..write('Connection: close\r\n') - ..write('\r\n'); + final requestBytes = utf8.encode(request.toString()); + final allBytes = [...requestBytes, ...(body is Uint8List ? body : Uint8List.fromList(body))]; + socket.write(Uint8List.fromList(allBytes)); - final requestBytes = utf8.encode(rawRequest.toString()); - final allBytes = [...requestBytes, ...(body is Uint8List ? body : Uint8List.fromList(body))]; - socket.write(Uint8List.fromList(allBytes)); + final responseBytes = []; + final completer = Completer(); + Timer? timer; - final responseBytes = []; - final completer = Completer(); - Timer? timer; - - socket.listen((event) { - if (event == RawSocketEvent.read) { - final data = socket.read(); - if (data != null) responseBytes.addAll(data); - } else if (event == RawSocketEvent.readClosed || event == RawSocketEvent.closed) { - timer?.cancel(); - if (responseBytes.isEmpty) { - completer.completeError(const SocketException('Пустой ответ сервера')); - return; - } - final headerEnd = _findHeaderEnd(responseBytes); - if (headerEnd == -1) { - completer.completeError(const SocketException('Не удалось прочитать заголовок ответа')); - return; - } - final headerStr = utf8.decode(responseBytes.sublist(0, headerEnd), allowMalformed: true); - final statusLine = headerStr.split('\r\n').first; - debugPrint('HTTP Response (raw): $statusLine'); - final parts = statusLine.split(' '); - completer.complete(parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0); - } - }, onError: (e) { + socket.listen((event) { + if (event == RawSocketEvent.read) { + final data = socket.read(); + if (data != null) responseBytes.addAll(data); + } else if (event == RawSocketEvent.readClosed || event == RawSocketEvent.closed) { timer?.cancel(); - completer.completeError(e); - }); + if (responseBytes.isEmpty) { + completer.completeError(const SocketException('Пустой ответ сервера')); + return; + } + final headerEnd = _findHeaderEnd(responseBytes); + if (headerEnd == -1) { + completer.completeError(const SocketException('Не удалось прочитать заголовок ответа')); + return; + } + final headerStr = utf8.decode(responseBytes.sublist(0, headerEnd), allowMalformed: true); + final statusLine = headerStr.split('\r\n').first; + debugPrint('HTTP Response: $statusLine'); + final parts = statusLine.split(' '); + completer.complete(parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0); + } + }, onError: (e) { + timer?.cancel(); + completer.completeError(e); + }); - timer = Timer(const Duration(minutes: 5), () { - socket.close(); - completer.completeError(TimeoutException('Тайм-аут загрузки')); - }); + timer = Timer(const Duration(minutes: 5), () { + socket.close(); + completer.completeError(TimeoutException('Тайм-аут загрузки')); + }); - return completer.future; - } + return completer.future; } int _findHeaderEnd(List bytes) { From 4297ebc3cf071a48cce163d70e02edc19276b5c5 Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 14 May 2026 14:41:52 +0300 Subject: [PATCH 14/43] =?UTF-8?q?perf:=20=D0=BA=D1=8D=D1=88=20=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D0=B0=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=87=D0=B0=D1=82=D0=BE=D0=B2=20=D0=BC=D0=B5=D0=B6?= =?UTF-8?q?=D0=B4=D1=83=20=D1=80=D0=B5=D0=B1=D0=B8=D0=BB=D0=B4=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index a2ffb88..bb9d2fa 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -107,6 +107,37 @@ class _ChatListScreenState extends State StreamSubscription? _stateSub; StreamSubscription? _loginSub; + Widget? _cachedChatsBody; + Object? _chatsBodyCacheKey; + + /// Возвращает дерево вкладки «Чаты», кэшируя его между ребилдами + /// родителя. Тап/драг навбара и FAB не трогают эти state-vars, + /// поэтому ключ остаётся прежним и subtree не пересобирается. + Widget _getChatsBody() { + final key = Object.hashAll([ + identityHashCode(_chats), + identityHashCode(_folders), + _selectedFolderId, + _isInitialLoading, + _foldersListKnown, + _showCacheWarning, + _isSelectionMode, + _shouldCollapseSearch, + _selectedChats.length, + _pullRatio, + _storiesDockedOpen, + _storiesAnimClosing, + _storiesOverscrollRevealArmed, + _sessionState, + identityHashCode(_profile), + ]); + if (_cachedChatsBody == null || _chatsBodyCacheKey != key) { + _chatsBodyCacheKey = key; + _cachedChatsBody = _buildChatsTabBody(); + } + return _cachedChatsBody!; + } + void _toggleSelection(String chatId) { setState(() { if (_selectedChats.contains(chatId)) { @@ -1406,7 +1437,7 @@ class _ChatListScreenState extends State child: SizedBox( width: pageW, height: pageH, - child: _buildChatsTabBody(), + child: _getChatsBody(), ), ), RepaintBoundary( From ef8c9f6cabf3862c3edcb18f384086eca9816366 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 14 May 2026 20:49:22 +0700 Subject: [PATCH 15/43] =?UTF-8?q?=D1=8D=D0=B2=D0=B5=D0=BD=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 6 +- lib/backend/modules/messages.dart | 21 +++- lib/core/storage/spoofing_service.dart | 2 +- lib/frontend/screens/chats/chat_screen.dart | 1 + lib/frontend/widgets/attachment_panel.dart | 109 ++++++++++++++++---- lib/frontend/widgets/message_bubble.dart | 90 +++++++++++++++- lib/models/attachment.dart | 11 +- 7 files changed, 205 insertions(+), 35 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 1fdd71f..d170153 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -122,11 +122,7 @@ class Api { Future sendHandshake() async { final deviceInfo = DeviceInfoPlugin(); - String deviceType = (Platform.isLinux || Platform.isWindows) - ? 'DESKTOP' - : (Platform.isAndroid) - ? 'ANDROID' - : 'IOS'; + String deviceType = 'ANDROID'; String osVersion = ''; String deviceName = 'Unknown'; String architecture = 'arm64'; diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 51af04e..06ac5c7 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -100,6 +100,7 @@ class CachedMessage { final String? status; final Map? payload; final List? attachments; + final bool isControl; const CachedMessage({ required this.id, @@ -111,6 +112,7 @@ class CachedMessage { this.status, this.payload, this.attachments, + this.isControl = false, }); factory CachedMessage.fromDbRow(Map row) { @@ -151,6 +153,7 @@ class CachedMessage { status: row['status']?.toString(), payload: payload, attachments: attachments, + isControl: attachments?.any((a) => a.type == AttachmentType.control) ?? false, ); } @@ -261,6 +264,7 @@ class MessagesModule { } List? attachments; + bool isControl = false; if (linkType == 'FORWARD') { final fwdMap = Map.from(m.cast()); attachments = [ForwardedMessageAttachment.fromMap(fwdMap)]; @@ -271,6 +275,11 @@ class MessagesModule { .whereType() .map((a) => MessageAttachment.fromMap(Map.from(a))) .toList(); + // Detect CONTROL + if (attachments.any((a) => a.type == AttachmentType.control)) { + isControl = true; + debugPrint('CONTROL detected: ${attachments.where((a) => a.type == AttachmentType.control).first}'); + } } } @@ -284,6 +293,7 @@ class MessagesModule { status: m['status']?.toString(), payload: Map.from(m.cast()), attachments: attachments, + isControl: isControl, ); } @@ -367,14 +377,21 @@ class MessagesModule { Future sendFileMessage( int chatId, int fileId, { + String? token, bool notify = true, }) async { final payload = { 'chatId': chatId, 'message': { - 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'isLive': false, + 'detectShare': false, + 'elements': [], + 'cid': DateTime.now().millisecondsSinceEpoch, 'attaches': [ - {'_type': 'FILE', 'fileId': fileId} + if (token != null) + {'_type': 'FILE', 'token': token} + else + {'_type': 'FILE', 'fileId': fileId} ], }, 'notify': notify, diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart index c27240b..548f5ad 100644 --- a/lib/core/storage/spoofing_service.dart +++ b/lib/core/storage/spoofing_service.dart @@ -1,7 +1,7 @@ import 'package:shared_preferences/shared_preferences.dart'; class SpoofingService { - static const String hardcodedAppVersion = '26.15.3'; + static const String hardcodedAppVersion = '26.14.1'; static const int hardcodedBuildNumber = 6606; static Future?> getSpoofedSessionData() async { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 9d50c57..73f1aed 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -384,6 +384,7 @@ class _ChatScreenState extends State itemCount: _messages.length, itemBuilder: (context, index) { final message = _messages[_messages.length - 1 - index]; + debugPrint('LIST_ITEM: ${message.id} isControl=${message.isControl} hasAttach=${message.attachments != null}'); final isMe = message.senderId == _myId; final prevMessage = index < _messages.length - 1 ? _messages[_messages.length - 2 - index] diff --git a/lib/frontend/widgets/attachment_panel.dart b/lib/frontend/widgets/attachment_panel.dart index f32a964..ffe3533 100644 --- a/lib/frontend/widgets/attachment_panel.dart +++ b/lib/frontend/widgets/attachment_panel.dart @@ -46,17 +46,6 @@ class _AttachmentPanelState extends State { return; } - final completer = Completer(); - void Function(Packet)? handler; - handler = (Packet packet) { - final payload = packet.payload; - if (payload is Map && payload['fileId'] == uploadInfo.fileId) { - api.unregisterPushHandler(Opcode.notifAttach); - completer.complete(); - } - }; - api.registerPushHandler(Opcode.notifAttach, (Packet p) => handler!(p)); - await api.sendRequest(Opcode.msgTyping, { 'chatId': widget.chatId, 'type': 'FILE', @@ -90,17 +79,61 @@ class _AttachmentPanelState extends State { statusCode = await _rawPost(secureSocket, uri, fileBytes, file.name); } - if (statusCode == 200) { - await completer.future.timeout( - const Duration(seconds: 30), + if (statusCode != 200) { + if (mounted) showCustomNotification(context, 'Ошибка загрузки: $statusCode'); + return; + } + + // Wait for notifAttach push + final pushCompleter = Completer(); + void Function(Packet)? pushHandler; + pushHandler = (Packet packet) { + final payload = packet.payload; + if (payload is Map && payload['fileId'] == uploadInfo.fileId) { + api.unregisterPushHandler(Opcode.notifAttach); + pushCompleter.complete(); + } + }; + api.registerPushHandler(Opcode.notifAttach, (Packet p) => pushHandler!(p)); + + await pushCompleter.future.timeout( + const Duration(seconds: 30), + onTimeout: () { + api.unregisterPushHandler(Opcode.notifAttach); + throw TimeoutException('Тайм-аут подтверждения загрузки'); + }, + ); + + // Retry loop: server may say "attachment in progress" (cmd=3) + for (var attempt = 0; attempt < 5; attempt++) { + final sent = await messagesModule.sendFileMessage( + widget.chatId, + uploadInfo.fileId, + token: uploadInfo.token, + ); + + // Listen for push again (another notifAttach may come) + final msgCompleter = Completer(); + void Function(Packet)? msgHandler; + msgHandler = (Packet packet) { + final payload = packet.payload; + if (payload is Map && payload['fileId'] == uploadInfo.fileId) { + api.unregisterPushHandler(Opcode.notifAttach); + msgCompleter.complete(true); + } + }; + api.registerPushHandler(Opcode.notifAttach, (Packet p) => msgHandler!(p)); + + final pushFuture = msgCompleter.future.timeout( + const Duration(seconds: 5), onTimeout: () { api.unregisterPushHandler(Opcode.notifAttach); - throw TimeoutException('Тайм-аут подтверждения загрузки'); + return false; }, ); - final sent = await messagesModule.sendFileMessage(widget.chatId, uploadInfo.fileId); - if (sent) { + final pushReceived = await pushFuture; + if (pushReceived && sent) { FileHistoryCache.add(FileHistoryEntry( fileId: uploadInfo.fileId, url: uploadInfo.url, @@ -111,13 +144,45 @@ class _AttachmentPanelState extends State { showCustomNotification(context, 'Файл отправлен'); widget.onClose(); } - } else { - if (mounted) showCustomNotification(context, 'Ошибка отправки сообщения'); + return; } - } else { - api.unregisterPushHandler(Opcode.notifAttach); - if (mounted) showCustomNotification(context, 'Ошибка загрузки: $statusCode'); + + // If push was received, check if message was sent + if (pushReceived) { + FileHistoryCache.add(FileHistoryEntry( + fileId: uploadInfo.fileId, + url: uploadInfo.url, + token: uploadInfo.token, + sentAt: DateTime.now(), + )); + if (mounted) { + showCustomNotification(context, 'Файл отправлен'); + widget.onClose(); + } + return; + } + + if (!sent) { + // msgSend failed, maybe server still processing — wait and retry + await Future.delayed(Duration(seconds: 1 + attempt)); + continue; + } + + // Sent ok, no push received (already processed earlier) + FileHistoryCache.add(FileHistoryEntry( + fileId: uploadInfo.fileId, + url: uploadInfo.url, + token: uploadInfo.token, + sentAt: DateTime.now(), + )); + if (mounted) { + showCustomNotification(context, 'Файл отправлен'); + widget.onClose(); + } + return; } + + if (mounted) showCustomNotification(context, 'Не удалось отправить сообщение'); } catch (e) { if (mounted) showCustomNotification(context, 'Ошибка: $e'); } finally { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 949ae9f..a945ccc 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -8,7 +8,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; import '../../models/attachment.dart'; -enum MessageType { text, attachment, voice } +enum MessageType { text, attachment, voice, control } enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle } @@ -56,18 +56,23 @@ class MessageBubble extends StatelessWidget { bool get isGroupedWithNext { if (nextMessage == null) return false; + if (message.isControl) return false; if (nextMessage!.senderId != message.senderId) return false; final timeDiff = nextMessage!.time - message.time; return timeDiff < 300000; } BubbleShape get shape { - final hasPrevFromMe = prevMessage?.senderId == message.senderId; + if (message.isControl) { + return BubbleShape.singleMiddle; + } + + final hasPrevFromMe = prevMessage?.senderId == message.senderId && !prevMessage!.isControl; final prevTimeDiff = hasPrevFromMe ? message.time - prevMessage!.time : 999999999; - final hasNextFromMe = nextMessage?.senderId == message.senderId; + final hasNextFromMe = nextMessage?.senderId == message.senderId && !nextMessage!.isControl; final nextTimeDiff = hasNextFromMe ? nextMessage!.time - message.time : 999999999; @@ -83,6 +88,7 @@ class MessageBubble extends StatelessWidget { } MessageType get contentType { + if (message.isControl) return MessageType.control; if (message.attachments != null && message.attachments!.isNotEmpty) { final first = message.attachments!.first; if (first is ForwardedMessageAttachment) { @@ -214,6 +220,8 @@ class MessageBubble extends StatelessWidget { case BubbleShape.groupedMiddle: return 1; } + case MessageType.control: + return 4; } return 4; } @@ -242,6 +250,17 @@ class MessageBubble extends StatelessWidget { case BubbleShape.groupedMiddle: return 1; } + case MessageType.attachment: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } case MessageType.voice: switch (shape) { case BubbleShape.singleTop: @@ -253,6 +272,8 @@ class MessageBubble extends StatelessWidget { case BubbleShape.groupedMiddle: return 1; } + case MessageType.control: + return 4; } return 4; } @@ -284,12 +305,22 @@ class MessageBubble extends StatelessWidget { case BubbleShape.singleMiddle: return const EdgeInsets.symmetric(horizontal: 14, vertical: 4); } + case MessageType.control: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 4); } return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); } @override Widget build(BuildContext context) { + if (message.isControl) { + debugPrint('BUILD CONTROL: ${message.id}'); + return Padding( + padding: EdgeInsets.only(top: topMargin, bottom: bottomMargin), + child: Center(child: _buildControlContent(context)), + ); + } + final cs = Theme.of(context).colorScheme; final isDark = cs.brightness == Brightness.dark; @@ -362,16 +393,67 @@ class MessageBubble extends StatelessWidget { Widget _buildContent(BuildContext context) { switch (contentType) { + case MessageType.control: + return _buildControlContent(context); case MessageType.attachment: return _buildAttachmentContent(context); case MessageType.voice: return _buildVoiceContent(context); case MessageType.text: - default: return _buildTextContent(context); } } + Widget _buildControlContent(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final attachments = message.attachments; + if (attachments == null || attachments.isEmpty) return const SizedBox.shrink(); + + final control = attachments.first; + if (control is! ControlAttachment) return const SizedBox.shrink(); + + String? text; + switch (control.event) { + case 'system': + text = control.title; + break; + case 'new': + text = '${ContactCache.get(message.senderId) ?? 'Пользователь'} создал(а) чат'; + break; + case 'add': + final names = (control.userIds ?? []).map((id) => ContactCache.get(id) ?? 'Пользователь').join(', '); + text = '${ContactCache.get(message.senderId) ?? 'Пользователь'} добавил(а) $names'; + break; + case 'leave': + text = '${ContactCache.get(message.senderId) ?? 'Пользователь'} покинул(а) чат'; + break; + case 'joinByLink': + text = '${ContactCache.get(message.senderId) ?? 'Пользователь'} присоединился(-ась) к чату'; + break; + default: + text = control.title; + } + + if (text == null || text.isEmpty) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + text, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ); + } + Widget _buildTextContent(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = cs.brightness == Brightness.dark; diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 795db87..dd76314 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -431,6 +431,7 @@ class ControlAttachment extends MessageAttachment { final String? event; final String? title; final List? userIds; + final int? userId; const ControlAttachment({ super.previewData, @@ -439,15 +440,22 @@ class ControlAttachment extends MessageAttachment { this.event, this.title, this.userIds, + this.userId, }) : super(type: AttachmentType.control); factory ControlAttachment.fromMap(Map map) { + String? title = map['title']?.toString(); + if ((title == null || title.isEmpty) && map['shortMessage'] != null) { + title = map['shortMessage'].toString(); + } + return ControlAttachment( previewData: map['previewData']?.toString(), baseUrl: map['baseUrl']?.toString(), event: map['event']?.toString(), - title: map['title']?.toString(), + title: title, userIds: (map['userIds'] as List?)?.map((e) => e is int ? e : int.tryParse(e?.toString() ?? '') ?? 0).toList(), + userId: map['userId'] is int ? map['userId'] as int : int.tryParse(map['userId']?.toString() ?? ''), ); } @@ -459,6 +467,7 @@ class ControlAttachment extends MessageAttachment { 'event': event, 'title': title, 'userIds': userIds, + 'userId': userId, }; } From 0f4ac31ae25a7a4e781376cdc036ac5e9e6342f5 Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 14 May 2026 17:06:11 +0300 Subject: [PATCH 16/43] =?UTF-8?q?feat:=20=D0=B3=D0=B0=D0=BB=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=83=20=D0=BE=D1=84=D0=B8=D1=86=D0=B8=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9,=20=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=BA=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 31 ++++++++++++-- lib/backend/modules/contacts.dart | 20 ++++++++++ lib/core/storage/app_database.dart | 14 ++++++- .../screens/chats/chat_list_screen.dart | 40 ++++++++++++++----- lib/frontend/screens/chats/chat_screen.dart | 35 ++++++++++++---- .../screens/contacts/contacts_tab.dart | 35 +++++++++++----- 6 files changed, 143 insertions(+), 32 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 2308163..8157055 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -38,6 +38,7 @@ class CachedChat { final bool isOnline; final int seenTime; final Map participants; + final Set options; const CachedChat({ required this.id, @@ -57,8 +58,11 @@ class CachedChat { required this.isOnline, required this.seenTime, required this.participants, + this.options = const {}, }); + bool get isOfficial => options.contains('OFFICIAL'); + factory CachedChat.fromDbRow(Map row) => CachedChat( id: row['id'] as int, accountId: row['account_id'] as int, @@ -76,9 +80,15 @@ class CachedChat { dontDisturbUntil: row['dont_disturb_until'] as int, isOnline: (row['is_online'] as int) == 1, seenTime: row['seen_time'] as int, - participants: _parseParticipants(row['participants']) + participants: _parseParticipants(row['participants']), + options: _decodeOptions(row['options']), ); + static Set _decodeOptions(dynamic raw) { + if (raw is! String || raw.isEmpty) return const {}; + return raw.split(',').where((s) => s.isNotEmpty).toSet(); + } + Map toDbRow() => { 'id': id, 'account_id': accountId, @@ -96,7 +106,8 @@ class CachedChat { 'dont_disturb_until': dontDisturbUntil, 'is_online': isOnline ? 1 : 0, 'seen_time': seenTime, - 'participants': jsonEncode(participants.map((k, v) => MapEntry(k.toString(), v))) + 'participants': jsonEncode(participants.map((k, v) => MapEntry(k.toString(), v))), + 'options': options.isEmpty ? null : options.join(','), }; } @@ -210,6 +221,7 @@ class ChatsModule { String? title; String? iconUrl; + Set options = const {}; if (type == 'DIALOG') { otherId = _otherParticipantId(chat['participants'], currentUserId); @@ -218,13 +230,25 @@ class ChatsModule { if (contact != null) { title = _nameFromContact(contact); iconUrl = contact['baseUrl'] as String?; + final contactOpts = contact['options']; + if (contactOpts is List) { + options = contactOpts.whereType().toSet(); + } } else { title = existing[id]?.title; iconUrl = existing[id]?.iconUrl; + options = existing[id]?.options ?? const {}; } } else { title = chat['title'] as String?; iconUrl = chat['baseIconUrl'] as String?; + final chatOpts = chat['options']; + if (chatOpts is Map) { + options = { + for (final entry in chatOpts.entries) + if (entry.value == true && entry.key is String) entry.key as String, + }; + } } final lastMsg = chat['lastMessage']; @@ -276,7 +300,8 @@ class ChatsModule { dontDisturbUntil: dontDisturbUntil, isOnline: isOnline, seenTime: seenTime, - participants: participants + participants: participants, + options: options, ); } catch (e) { logger.e("Ошибка при парсинге чата: $e"); diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index b4f103b..fda11db 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -11,6 +11,7 @@ class CachedContact { final String? baseUrl; final String? baseRawUrl; final int updateTime; + final Set options; const CachedContact({ required this.id, @@ -22,8 +23,14 @@ class CachedContact { this.baseUrl, this.baseRawUrl, required this.updateTime, + this.options = const {}, }); + bool get isOfficial => options.contains('OFFICIAL'); + bool get isBot => options.contains('BOT'); + bool get isServiceAccount => options.contains('SERVICE_ACCOUNT'); + bool get isVerified => isOfficial || isBot || isServiceAccount; + factory CachedContact.fromDbRow(Map row) => CachedContact( id: row['id'] as int, accountId: row['account_id'] as int, @@ -34,7 +41,13 @@ class CachedContact { baseUrl: row['base_url'] as String?, baseRawUrl: row['base_raw_url'] as String?, updateTime: row['update_time'] as int, + options: _decodeOptions(row['options']), ); + + static Set _decodeOptions(dynamic raw) { + if (raw is! String || raw.isEmpty) return const {}; + return raw.split(',').where((s) => s.isNotEmpty).toSet(); + } } class ContactsModule { @@ -126,6 +139,12 @@ class ContactsModule { lastName = name['lastName'] as String?; } + final optionsRaw = contact['options']; + String? optionsStr; + if (optionsRaw is List) { + optionsStr = optionsRaw.whereType().join(','); + } + return { 'id': id, 'account_id': accountId, @@ -136,6 +155,7 @@ class ContactsModule { 'base_url': contact['baseUrl'] as String?, 'base_raw_url': contact['baseRawUrl'] as String?, 'update_time': (contact['updateTime'] as int?) ?? 0, + 'options': optionsStr, }; } } diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 24f02d9..7f7ff13 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -159,7 +159,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 8, + version: 9, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -193,6 +193,14 @@ class AppDatabase { 'ALTER TABLE chats_cache ADD COLUMN participants TEXT', ); } + if (oldVersion < 9) { + await db.execute( + 'ALTER TABLE contacts ADD COLUMN options TEXT', + ); + await db.execute( + 'ALTER TABLE chats_cache ADD COLUMN options TEXT', + ); + } }, ); } @@ -230,7 +238,8 @@ class AppDatabase { photo_id INTEGER, base_url TEXT, base_raw_url TEXT, - update_time INTEGER NOT NULL DEFAULT 0 + update_time INTEGER NOT NULL DEFAULT 0, + options TEXT ) '''; @@ -262,6 +271,7 @@ class AppDatabase { is_online INTEGER NOT NULL DEFAULT 0, seen_time INTEGER NOT NULL DEFAULT 0, participants TEXT NOT NULL DEFAULT "", + options TEXT, PRIMARY KEY (id, account_id) ) '''; diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index bb9d2fa..57eb64e 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1102,6 +1102,7 @@ class _ChatListScreenState extends State isOnline: chat.isOnline, unreadCount: chat.unreadCount, isMuted: chat.dontDisturbUntil > 0, + isVerified: chat.isOfficial, chatType: "DIALOG", ); } else { @@ -1134,6 +1135,7 @@ class _ChatListScreenState extends State isOnline: chat.isOnline, unreadCount: chat.unreadCount, isMuted: chat.dontDisturbUntil > 0, + isVerified: chat.isOfficial, chatType: chat.type, ); } @@ -1749,6 +1751,7 @@ class _ChatListScreenState extends State bool isRead = false, int unreadCount = 0, bool isMuted = false, + bool isVerified = false, String chatType = "CHAT", }) { final cs = Theme.of(context).colorScheme; @@ -1849,16 +1852,33 @@ Navigator.push( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - height: 1.1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isVerified) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], + ], ), ), if (isMuted) ...[ diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 9d50c57..87a6bc8 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -304,14 +304,33 @@ class _ChatScreenState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - widget.name, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + widget.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (chat?.isOfficial ?? false) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], + ], ), Text( status ?? "", diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index a2d00d6..8acc382 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -100,15 +100,32 @@ class _ContactsTabState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - nameToDisplay, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + nameToDisplay, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (contact.isVerified) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], + ], ), const SizedBox(height: 4), Text( From 9e293c12508bbe02e942c502beb3ee3264bf97d1 Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 14 May 2026 18:01:36 +0300 Subject: [PATCH 17/43] =?UTF-8?q?fix:=20Zstd=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20pure-Dart=20libcompress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/protocol/packet.dart | 5 ++--- pubspec.lock | 24 ++++++++---------------- pubspec.yaml | 2 +- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index e8b3e27..d8527a1 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -1,7 +1,7 @@ import 'dart:typed_data'; import 'dart:isolate'; import 'package:dart_lz4/dart_lz4.dart'; -import 'package:es_compression/zstd.dart'; +import 'package:libcompress/libcompress.dart'; import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; /// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10 @@ -162,8 +162,7 @@ Uint8List _decompressPayload(Uint8List src) { src[2] == 0x2F && src[3] == 0xFD) { try { - final out = zstd.decode(src); - return out is Uint8List ? out : Uint8List.fromList(out); + return ZstdCodec().decompress(src); } catch (e) { throw Exception('Zstd decompression error: $e'); } diff --git a/pubspec.lock b/pubspec.lock index d510664..00c6969 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" async: dependency: transitive description: @@ -145,14 +137,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.8" - es_compression: - dependency: "direct main" - description: - name: es_compression - sha256: c1ff7af54802631cf5c3942cb67bb99daadcc087f573ca99a9de91002d1a7ece - url: "https://pub.dev" - source: hosted - version: "2.0.15" fake_async: dependency: transitive description: @@ -365,6 +349,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + libcompress: + dependency: "direct main" + description: + name: libcompress + sha256: "1f55be8dc9e622efa1584ad899e05880d71469b7107f35be1858e91d0fadf1d4" + url: "https://pub.dev" + source: hosted + version: "1.0.0" lints: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cf9fd81..784ef10 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 dart_lz4: ^1.0.0 - es_compression: ^2.0.15 + libcompress: ^1.0.0 msgpack_dart: ^1.0.1 logger: ^2.6.2 device_info_plus: 12.3.0 From fd4f40a527da30e1e6257225ceb49248abace1d4 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 14 May 2026 22:58:40 +0700 Subject: [PATCH 18/43] =?UTF-8?q?=D0=BF=D0=BE=D1=87=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B2=D1=85=D0=BE=D0=B4=20=D1=81=202fa=20=D0=B8=20?= =?UTF-8?q?=D1=8D=D0=B2=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B8=20=D1=82=D0=B0?= =?UTF-8?q?=D0=BC=20=D0=B5=D1=89=D0=B5=20=D1=87=D0=B5=D1=82=D0=BE=20=D0=B1?= =?UTF-8?q?=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 56 +++++++++---------- .../screens/auth/password_2fa_screen.dart | 4 +- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 288a097..1a983be 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'package:flutter/material.dart' show Locale; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; @@ -803,15 +802,18 @@ class AccountModule { }) async { _ensureOnline(); - final resolvedAccountId = + int? resolvedAccountId = accountId ?? await TokenStorage.getActiveAccountId(); - if (resolvedAccountId == null) { - throw StateError('login: нет активного аккаунта'); - } - final authToken = token ?? await TokenStorage.readToken(resolvedAccountId); + String? authToken = token; if (authToken == null) { - throw StateError('login: нет токена для аккаунта $resolvedAccountId'); + if (resolvedAccountId == null) { + throw StateError('login: нет активного аккаунта'); + } + authToken = await TokenStorage.readToken(resolvedAccountId); + if (authToken == null) { + throw StateError('login: нет токена для аккаунта $resolvedAccountId'); + } } final requestPayload = _buildLoginPayload(authToken, syncParams); @@ -827,10 +829,24 @@ class AccountModule { throw Exception('login: неожиданный тип payload: ${data.runtimeType}'); } - final result = await _processLoginResponse( - data.cast(), - resolvedAccountId, - ); + final dataMap = data.cast(); + + if (resolvedAccountId == null) { + final profileMap = dataMap['profile']; + if (profileMap is Map) { + final contact = profileMap['contact']; + if (contact is Map) { + resolvedAccountId = contact['id'] as int?; + } + } + if (resolvedAccountId == null) { + throw Exception('login: не удалось определить accountId из ответа'); + } + await TokenStorage.saveToken(authToken, resolvedAccountId); + await TokenStorage.setActiveAccount(resolvedAccountId); + } + + final result = await _processLoginResponse(dataMap, resolvedAccountId); _loginStatusController.add(LoginStatus.success); return result; } catch (e) { @@ -938,23 +954,7 @@ class AccountModule { throw Exception('checkPassword: отсутствует токен в ответе'); } - final profileData = data['profile']; - int? accountId; - if (profileData is Map) { - final contact = profileData['contact']; - if (contact is Map) { - accountId = contact['id'] as int?; - } - } - - if (accountId != null) { - await TokenStorage.saveToken(loginToken, accountId); - await TokenStorage.setActiveAccount(accountId); - logger.i('2FA пройдена, токен аккаунта $accountId сохранён'); - } else { - logger.w('2FA пройдена, но accountId не получен из ответа'); - } - + logger.i('2FA пройдена, получен login-токен'); return TwoFactorResult(loginToken: loginToken); } diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index 9a99a42..b38171e 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -33,14 +33,14 @@ class _Password2FAScreenState extends State { }); try { - await accountModule.checkPassword( + final result = await accountModule.checkPassword( password: _passwordController.text, trackId: widget.trackId, ); if (!mounted) return; - await accountModule.login(); + await accountModule.login(token: result.loginToken); if (!mounted) return; From 5db3b2d13569a30fba4035d11d3cbbf19182d2eb Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 15 May 2026 07:26:27 +0300 Subject: [PATCH 19/43] =?UTF-8?q?feat:=20=D1=82=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BE=D1=82=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B0=20+=20=D1=82=D1=83=D0=BC=D0=B1=D0=BB=D0=B5=D1=80=20?= =?UTF-8?q?=D0=B2=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/utils/haptics.dart | 83 +++++++++++++++++++ .../screens/chats/chat_list_screen.dart | 5 ++ lib/frontend/screens/chats/chat_screen.dart | 6 ++ .../screens/profile/settings_tab.dart | 52 ++++++++++-- lib/frontend/widgets/message_bubble.dart | 3 +- lib/main.dart | 3 + 6 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 lib/core/utils/haptics.dart diff --git a/lib/core/utils/haptics.dart b/lib/core/utils/haptics.dart new file mode 100644 index 0000000..b21328d --- /dev/null +++ b/lib/core/utils/haptics.dart @@ -0,0 +1,83 @@ +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Centralized tactile feedback for Komet. +/// +/// Wraps Flutter's [HapticFeedback] so the whole app speaks one tactile +/// "language": the same gesture always feels the same. Composite patterns +/// chain impacts with short delays to produce richer, more memorable +/// sensations than a single buzz. +/// +/// Every call is best-effort and silent on failure — a device without a +/// vibrator (or with system haptics disabled) must never crash the UI. +class Haptics { + Haptics._(); + + static const String _prefKey = 'haptics_enabled'; + + /// Master switch. Silences every haptic app-wide when `false`. + /// Controlled by the user via Settings; persisted across launches. + static bool enabled = true; + + /// Restores the saved preference. Call once during app startup, + /// before the first frame. Defaults to enabled when never set. + static Future load() async { + try { + final prefs = await SharedPreferences.getInstance(); + enabled = prefs.getBool(_prefKey) ?? true; + } catch (_) { + enabled = true; + } + } + + /// Updates the master switch and persists it. + static Future setEnabled(bool value) async { + enabled = value; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefKey, value); + } catch (_) { + // Persistence is best-effort; the in-memory switch still applies. + } + } + + static Future _fire(Future Function() effect) async { + if (!enabled) return; + try { + await effect(); + } catch (_) { + // Intentionally swallowed: haptics are a nicety, never a hard dependency. + } + } + + /// A crisp, light tick — taps, toggles, opening panels. + static Future tap() => _fire(HapticFeedback.lightImpact); + + /// A firmer press — confirmations, entering a mode. + static Future medium() => _fire(HapticFeedback.mediumImpact); + + /// A strong thud — destructive or weighty actions. + static Future heavy() => _fire(HapticFeedback.heavyImpact); + + /// The subtle detent of moving between discrete options — tabs, selection. + static Future selection() => _fire(HapticFeedback.selectionClick); + + /// Message sent: a quick, instant tick (the "whoosh"). + static Future send() => tap(); + + /// A two-beat rising pulse — success, completion, "it landed". + static Future success() async { + if (!enabled) return; + await _fire(HapticFeedback.lightImpact); + await Future.delayed(const Duration(milliseconds: 90)); + await _fire(HapticFeedback.mediumImpact); + } + + /// A double thud — errors, rejected or failed actions. + static Future error() async { + if (!enabled) return; + await _fire(HapticFeedback.heavyImpact); + await Future.delayed(const Duration(milliseconds: 120)); + await _fire(HapticFeedback.heavyImpact); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 57eb64e..2de522b 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -12,6 +12,7 @@ import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; import '../profile/settings_tab.dart'; import '../../../backend/api.dart'; +import '../../../core/utils/haptics.dart'; import '../../../backend/models/chat_folder.dart'; import '../../../backend/modules/account.dart'; import '../../../backend/modules/chats.dart'; @@ -139,6 +140,7 @@ class _ChatListScreenState extends State } void _toggleSelection(String chatId) { + Haptics.selection(); setState(() { if (_selectedChats.contains(chatId)) { _selectedChats.remove(chatId); @@ -729,6 +731,8 @@ class _ChatListScreenState extends State if (index == _currentNavIndex && !_navPageAnimController.isAnimating) { return; } + // Detent "click" when crossing into a different tab. + Haptics.selection(); double fromT; if (_navPageAnimController.isAnimating) { final t = Curves.easeOutCubic.transform(_navPageAnimController.value); @@ -743,6 +747,7 @@ class _ChatListScreenState extends State } void _toggleFab() { + Haptics.tap(); setState(() { _isFabOpen = !_isFabOpen; if (_isFabOpen) { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 655506a..068939c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -8,6 +8,7 @@ import '../../../main.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/haptics.dart'; import '../../../models/attachment.dart'; import '../../../backend/modules/messages.dart' show ContactCache; import '../../widgets/message_bubble.dart'; @@ -153,6 +154,10 @@ class _ChatScreenState extends State _hasText = false; }); + // Instant tactile "whoosh" the moment the message leaves the composer, + // not after the network round-trip — feedback must feel immediate. + Haptics.send(); + _scrollToBottom(); await messagesModule.sendMessage(_myId, widget.chatId, text); @@ -173,6 +178,7 @@ class _ChatScreenState extends State } } catch (e) { debugPrint('Error sending message: $e'); + Haptics.error(); } finally { setState(() { _isSending = false; diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index f38811b..313abef 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../auth/proxy_settings_sheet.dart'; @@ -30,6 +31,7 @@ class _SettingsTabState extends State { int _versionSecretTapCount = 0; Timer? _versionSecretTapResetTimer; StreamSubscription? _profileUpdateSub; + bool _hapticsEnabled = Haptics.enabled; @override void initState() { @@ -83,6 +85,13 @@ class _SettingsTabState extends State { }); } + Future _setHaptics(bool value) async { + await Haptics.setEnabled(value); + // Let the user *feel* the confirmation the instant they switch it on. + if (value) Haptics.success(); + if (mounted) setState(() => _hapticsEnabled = value); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -144,6 +153,12 @@ child: _buildSection( icon: Symbols.notifications_active, label: 'Уведомления и звук', ), + _SettingsItem( + icon: Symbols.vibration, + label: 'Тактильная отдача', + toggleValue: _hapticsEnabled, + onToggle: _setHaptics, + ), _SettingsItem( icon: Symbols.vpn_lock, label: 'Прокси', @@ -450,7 +465,9 @@ child: _buildSection( Material( color: Colors.transparent, child: InkWell( - onTap: item.onTap ?? () {}, + onTap: item.isToggle + ? () => item.onToggle!(!(item.toggleValue ?? false)) + : (item.onTap ?? () {}), borderRadius: isLast ? const BorderRadius.vertical(bottom: Radius.circular(20)) : null, @@ -475,12 +492,18 @@ child: _buildSection( ), ), ), - Icon( - Symbols.chevron_right, - color: cs.outline, - size: 20, - weight: 400, - ), + if (item.isToggle) + Switch.adaptive( + value: item.toggleValue ?? false, + onChanged: item.onToggle, + ) + else + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), ], ), ), @@ -505,7 +528,20 @@ class _SettingsItem { final String label; final VoidCallback? onTap; - const _SettingsItem({required this.icon, required this.label, this.onTap}); + /// When [onToggle] is set the row renders a trailing switch instead of a + /// chevron, and [toggleValue] reflects its current state. + final bool? toggleValue; + final ValueChanged? onToggle; + + const _SettingsItem({ + required this.icon, + required this.label, + this.onTap, + this.toggleValue, + this.onToggle, + }); + + bool get isToggle => onToggle != null; } class _PhoneSpoiler extends StatefulWidget { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index a945ccc..fdbe793 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -6,6 +6,7 @@ import 'package:komet/main.dart'; import 'package:flutter/foundation.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; +import '../../core/utils/haptics.dart'; import '../../models/attachment.dart'; enum MessageType { text, attachment, voice, control } @@ -329,7 +330,7 @@ class MessageBubble extends StatelessWidget { return GestureDetector( // TODO: действия с сообщением - onTap: () => print("test"), + onTap: () => Haptics.tap(), child: Padding( padding: EdgeInsets.only( left: isMe ? 12 : 12, diff --git a/lib/main.dart b/lib/main.dart index 37f1354..e78be9a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'backend/modules/contacts.dart'; import 'backend/modules/messages.dart'; import 'core/storage/app_database.dart'; import 'core/storage/token_storage.dart'; +import 'core/utils/haptics.dart'; import 'core/protocol/packet.dart'; import 'frontend/debug/fps_overlay_layer.dart'; import 'frontend/screens/auth/login_screen.dart'; @@ -44,6 +45,8 @@ void main() async { await api.connect(); final initialLocale = await _loadInitialLocale(); + await Haptics.load(); + final prefs = await SharedPreferences.getInstance(); final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; runApp( From 74917dd3ecd67b3e8552d9932b0ad8bd4297e685 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 15 May 2026 16:26:19 +0700 Subject: [PATCH 20/43] =?UTF-8?q?=D0=B4=D0=B0=D1=82=D1=8B,=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=87=D0=B8=D0=BD=D0=B8=D0=BB=20=D0=BA=D0=B0=D0=BA=D1=83=D1=8E?= =?UTF-8?q?=20=D1=82=D0=BE=20=D1=85=D1=83=D0=B9=D0=BD=D1=8E=20=D1=81=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 5 +- lib/frontend/screens/chats/chat_screen.dart | 259 ++++++++++++++++-- lib/main.dart | 8 +- 3 files changed, 242 insertions(+), 30 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 2de522b..13ebc9c 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1341,7 +1341,9 @@ class _ChatListScreenState extends State ), ), ), - Row( + SizedBox( + width: navInnerW, + child: Row( children: List.generate(4, (index) { IconData icon; String label; @@ -1389,6 +1391,7 @@ class _ChatListScreenState extends State ), ); }), + ), ), ], ), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 068939c..e37aca3 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -14,6 +14,18 @@ import '../../../backend/modules/messages.dart' show ContactCache; import '../../widgets/message_bubble.dart'; import '../../widgets/attachment_panel.dart'; +class _DateSeparatorItem { + final DateTime date; + final GlobalKey key; + _DateSeparatorItem(this.date, this.key); +} + +class _MessageItem { + final CachedMessage message; + final int index; + const _MessageItem(this.message, this.index); +} + class ChatScreen extends StatefulWidget { final int chatId; final String name; @@ -33,9 +45,10 @@ class ChatScreen extends StatefulWidget { } class _ChatScreenState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); + final GlobalKey _listKey = GlobalKey(); bool _hasText = false; bool _isLoading = true; bool _isSending = false; @@ -44,15 +57,28 @@ class _ChatScreenState extends State List _messages = []; int _myId = 0; CachedChat? chat; + + DateTime? _floatingDate; + DateTime? _lastFloatingDate; + Timer? _floatingDateTimer; + late final AnimationController _floatingDateAnimController; + final Map _separatorKeys = {}; + double _lastScrollOffset = 0; @override void initState() { super.initState(); _messageController.addListener(_onTextChanged); + _scrollController.addListener(_onScrollForDate); _shimmerController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), )..repeat(); + _floatingDateAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + reverseDuration: const Duration(milliseconds: 380), + ); _loadHistory(); } @@ -111,6 +137,9 @@ class _ChatScreenState extends State @override void dispose() { _messageController.removeListener(_onTextChanged); + _scrollController.removeListener(_onScrollForDate); + _floatingDateTimer?.cancel(); + _floatingDateAnimController.dispose(); _messageController.dispose(); _scrollController.dispose(); _shimmerController.dispose(); @@ -257,6 +286,143 @@ class _ChatScreenState extends State }); } + List _buildCombinedItems() { + final List items = []; + final Set usedDates = {}; + + for (int i = 0; i < _messages.length; i++) { + final msg = _messages[i]; + final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time); + final dayMillis = DateTime(msgDate.year, msgDate.month, msgDate.day) + .millisecondsSinceEpoch; + + bool needSeparator = i == 0; + if (!needSeparator) { + final prevDate = + DateTime.fromMillisecondsSinceEpoch(_messages[i - 1].time); + final prevDayMillis = + DateTime(prevDate.year, prevDate.month, prevDate.day) + .millisecondsSinceEpoch; + needSeparator = dayMillis != prevDayMillis; + } + + if (needSeparator) { + _separatorKeys.putIfAbsent(dayMillis, () => GlobalKey()); + usedDates.add(dayMillis); + items.add(_DateSeparatorItem( + DateTime.fromMillisecondsSinceEpoch(dayMillis), + _separatorKeys[dayMillis]!, + )); + } + + items.add(_MessageItem(msg, i)); + } + + _separatorKeys.removeWhere((k, _) => !usedDates.contains(k)); + return items; + } + + void _onScrollForDate() { + if (!_scrollController.hasClients) return; + final currentOffset = _scrollController.position.pixels; + final scrollingUp = currentOffset > _lastScrollOffset; + _lastScrollOffset = currentOffset; + + _floatingDateTimer?.cancel(); + + if (!scrollingUp) { + _floatingDateAnimController.reverse(); + return; + } + + _floatingDateTimer = Timer(const Duration(seconds: 2), () { + if (mounted) _floatingDateAnimController.reverse(); + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _updateFloatingDate()); + } + + void _updateFloatingDate() { + if (!mounted) return; + DateTime? result; + + final listRenderBox = _listKey.currentContext?.findRenderObject(); + if (listRenderBox is! RenderBox) return; + + _separatorKeys.forEach((dayMillis, gkey) { + final ctx = gkey.currentContext; + if (ctx == null) return; + final box = ctx.findRenderObject(); + if (box is! RenderBox) return; + final pos = box.localToGlobal(Offset.zero, ancestor: listRenderBox); + if (pos.dy + box.size.height < 4) { + final date = DateTime.fromMillisecondsSinceEpoch(dayMillis); + if (result == null || date.isAfter(result!)) { + result = date; + } + } + }); + + if (result == null) return; + + final bool dateChanged = result != _lastFloatingDate; + _lastFloatingDate = result; + + if (result != _floatingDate) { + setState(() => _floatingDate = result); + } + + if (dateChanged) { + _floatingDateAnimController.forward(from: 0); + } else { + _floatingDateAnimController.forward(); + } + } + + String _formatDateLabel(DateTime date) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final d = DateTime(date.year, date.month, date.day); + + if (d == today) return 'Сегодня'; + if (d == yesterday) return 'Вчера'; + + const months = [ + 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', + 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря', + ]; + if (date.year == now.year) { + return '${date.day} ${months[date.month - 1]}'; + } + return '${date.day} ${months[date.month - 1]} ${date.year}'; + } + + Widget _buildDateSeparatorWidget(BuildContext context, DateTime date, + {Key? key}) { + final cs = Theme.of(context).colorScheme; + return Padding( + key: key, + padding: const EdgeInsets.symmetric(vertical: 8), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _formatDateLabel(date), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -402,31 +568,74 @@ class _ChatScreenState extends State ); } - return ListView.builder( - controller: _scrollController, - reverse: true, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _messages.length, - itemBuilder: (context, index) { - final message = _messages[_messages.length - 1 - index]; - debugPrint('LIST_ITEM: ${message.id} isControl=${message.isControl} hasAttach=${message.attachments != null}'); - final isMe = message.senderId == _myId; - final prevMessage = index < _messages.length - 1 - ? _messages[_messages.length - 2 - index] - : null; - final nextMessage = index > 0 - ? _messages[_messages.length - index] - : null; + final items = _buildCombinedItems(); - return MessageBubble( - message: message, - isMe: isMe, - myId: _myId, - prevMessage: prevMessage, - nextMessage: nextMessage, - chatType: chat?.type ?? 'CHAT', - ); - }, + return Stack( + key: _listKey, + children: [ + ListView.builder( + controller: _scrollController, + reverse: true, + padding: const EdgeInsets.symmetric(vertical: 8), + cacheExtent: 9999, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[items.length - 1 - index]; + + if (item is _DateSeparatorItem) { + return _buildDateSeparatorWidget(context, item.date, + key: item.key); + } + + final msgItem = item as _MessageItem; + final message = msgItem.message; + final msgIndex = msgItem.index; + debugPrint( + 'LIST_ITEM: ${message.id} isControl=${message.isControl} hasAttach=${message.attachments != null}'); + final isMe = message.senderId == _myId; + final prevMessage = + msgIndex > 0 ? _messages[msgIndex - 1] : null; + final nextMessage = msgIndex < _messages.length - 1 + ? _messages[msgIndex + 1] + : null; + + return MessageBubble( + message: message, + isMe: isMe, + myId: _myId, + prevMessage: prevMessage, + nextMessage: nextMessage, + chatType: chat?.type ?? 'CHAT', + ); + }, + ), + if (_lastFloatingDate != null) + Positioned( + top: 8, + left: 0, + right: 0, + child: IgnorePointer( + child: AnimatedBuilder( + animation: _floatingDateAnimController, + builder: (context, child) { + final t = CurvedAnimation( + parent: _floatingDateAnimController, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + ).value; + return Opacity( + opacity: t, + child: Transform.scale( + scale: 0.82 + 0.18 * t, + child: child, + ), + ); + }, + child: _buildDateSeparatorWidget(context, _lastFloatingDate!), + ), + ), + ), + ], ); } diff --git a/lib/main.dart b/lib/main.dart index e78be9a..c7623f4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -281,16 +281,16 @@ class _StartupScreenState extends State<_StartupScreen> { return; } + try { + await accountModule.login(accountId: accountId); + } catch (_) {} + if (mounted) { Navigator.pushReplacement( context, MaterialPageRoute(builder: (_) => const ChatListScreen()), ); } - - try { - await accountModule.login(accountId: accountId); - } catch (_) {} } void _goToLogin() { From f093e7c802417fe1aa6d1e1c95e090abb9c4169d Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 15 May 2026 17:02:22 +0700 Subject: [PATCH 21/43] =?UTF-8?q?=D0=BF=D0=BE=D1=87=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B3=D0=B0=D0=BB=D0=BE=D1=87=D0=BA=D0=B8,=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D1=80=D0=B5=D0=BF=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=87=D0=B0=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 2 + lib/backend/modules/contacts.dart | 2 +- lib/backend/modules/messages.dart | 17 +++-- .../screens/chats/chat_list_screen.dart | 63 +++++++++++++++---- 4 files changed, 66 insertions(+), 18 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 8157055..e2251d2 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -132,6 +132,7 @@ class ChatsModule { final chatsConfig = configMap['chats'] is Map ? configMap['chats'] as Map : {}; + // Presence for online statuses final presenceMap = data['presence'] is Map ? data['presence'] as Map : {}; final cachedAt = DateTime.now().millisecondsSinceEpoch; @@ -272,6 +273,7 @@ class ChatsModule { dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0; } + int seenTime = 0; bool isOnline = false; if (type == 'DIALOG' && otherId != null) { diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index fda11db..ade6ad3 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -29,7 +29,7 @@ class CachedContact { bool get isOfficial => options.contains('OFFICIAL'); bool get isBot => options.contains('BOT'); bool get isServiceAccount => options.contains('SERVICE_ACCOUNT'); - bool get isVerified => isOfficial || isBot || isServiceAccount; + bool get isVerified => isOfficial; factory CachedContact.fromDbRow(Map row) => CachedContact( id: row['id'] as int, diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 06ac5c7..b2b4b5c 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -8,19 +8,19 @@ import '../../models/attachment.dart'; class ContactCache { static final Map _nameCache = {}; static final Map _avatarCache = {}; + static final Map> _optionsCache = {}; - static void put(int id, String name) { - _nameCache[id] = name; - } + static void put(int id, String name) => _nameCache[id] = name; static void putAvatar(int id, String? baseUrl) { - if (baseUrl != null) { - _avatarCache[id] = baseUrl; - } + if (baseUrl != null) _avatarCache[id] = baseUrl; } + static void putOptions(int id, Set opts) => _optionsCache[id] = opts; + static String? get(int id) => _nameCache[id]; static String? getAvatar(int id) => _avatarCache[id]; + static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false; } class TranscriptionResult { @@ -545,6 +545,11 @@ class MessagesModule { final baseUrl = contact['baseUrl'] as String?; ContactCache.putAvatar(contactId, baseUrl); + final rawOpts = contact['options']; + if (rawOpts is List) { + ContactCache.putOptions(contactId, rawOpts.whereType().toSet()); + } + return fullName; } } diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 13ebc9c..906dadc 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -376,13 +376,21 @@ class _ChatListScreenState extends State } List _chatsForPageIndex(int pageIndex) { - if (_folders.isEmpty) return _chats; - if (pageIndex < 0 || pageIndex >= _folders.length) return _chats; - final folder = _folders[pageIndex]; - if (FoldersModule.isAllChatsFolder(folder)) return _chats; - return _chats - .where((c) => FoldersModule.chatMatchesFolder(c, folder)) - .toList(); + List base; + if (_folders.isEmpty) { + base = _chats; + } else if (pageIndex < 0 || pageIndex >= _folders.length) { + base = _chats; + } else { + final folder = _folders[pageIndex]; + base = FoldersModule.isAllChatsFolder(folder) + ? _chats + : _chats.where((c) => FoldersModule.chatMatchesFolder(c, folder)).toList(); + } + final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList() + ..sort((a, b) => a.favIndex!.compareTo(b.favIndex!)); + final regular = base.where((c) => (c.favIndex ?? 0) <= 0).toList(); + return [...pinned, ...regular]; } void _syncFolderChatScrollControllers() { @@ -1088,7 +1096,25 @@ class _ChatListScreenState extends State if (_isInitialLoading) { return _buildChatShimmer(); } - final chat = chats[index]; + + final pinnedCount = chats.where((c) => (c.favIndex ?? 0) > 0).length; + final hasSeparator = pinnedCount > 0 && pinnedCount < chats.length; + + // Insert separator row between pinned and regular sections + if (hasSeparator && index == pinnedCount) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Divider( + height: 1, + thickness: 0.5, + color: cs.outlineVariant.withValues(alpha: 0.5), + ), + ); + } + + final chatIndex = hasSeparator && index > pinnedCount ? index - 1 : index; + final chat = chats[chatIndex]; + final isPinned = (chat.favIndex ?? 0) > 0; if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { final secondId = chat.participants.entries @@ -1097,6 +1123,9 @@ class _ChatListScreenState extends State .key; final name = ContactCache.get(secondId); final avatar = ContactCache.getAvatar(secondId); + // ContactCache.isOfficial covers contacts loaded via opcode 32; + // chat.isOfficial covers contacts from the login payload. + final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial; return _buildChatItem( chat.id.toString(), @@ -1107,7 +1136,8 @@ class _ChatListScreenState extends State isOnline: chat.isOnline, unreadCount: chat.unreadCount, isMuted: chat.dontDisturbUntil > 0, - isVerified: chat.isOfficial, + isVerified: isVerified, + isPinned: isPinned, chatType: "DIALOG", ); } else { @@ -1124,7 +1154,7 @@ class _ChatListScreenState extends State if (name?.isNotEmpty == true && chat.id != 0) { fullMsg += "$name: "; } - + if (chat.lastMsgText?.isNotEmpty == true) { fullMsg += chat.lastMsgText ?? ""; } @@ -1141,10 +1171,11 @@ class _ChatListScreenState extends State unreadCount: chat.unreadCount, isMuted: chat.dontDisturbUntil > 0, isVerified: chat.isOfficial, + isPinned: isPinned, chatType: chat.type, ); } - }, childCount: _isInitialLoading ? 10 : chats.length), + }, childCount: _isInitialLoading ? 10 : chats.length + (chats.any((c) => (c.favIndex ?? 0) > 0) && chats.any((c) => (c.favIndex ?? 0) <= 0) ? 1 : 0)), ), SliverPadding( padding: EdgeInsets.only( @@ -1760,6 +1791,7 @@ class _ChatListScreenState extends State int unreadCount = 0, bool isMuted = false, bool isVerified = false, + bool isPinned = false, String chatType = "CHAT", }) { final cs = Theme.of(context).colorScheme; @@ -1898,6 +1930,15 @@ Navigator.push( weight: 400, ), ], + if (isPinned) ...[ + const SizedBox(width: 4), + Icon( + Symbols.keep, + color: cs.outlineVariant, + size: 14, + weight: 400, + ), + ], const SizedBox(width: 8), Text( time, From 0431db1c77f115dd6de989cbcd3e5e895719f3ad Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 15 May 2026 18:58:58 +0700 Subject: [PATCH 22/43] =?UTF-8?q?=D0=B8=D0=BD=D1=84=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 20 +- .../screens/chats/chat_info_screen.dart | 1220 +++++++++++++---- .../screens/chats/chat_list_screen.dart | 4 +- lib/frontend/screens/chats/chat_screen.dart | 178 ++- lib/frontend/widgets/message_bubble.dart | 72 +- 5 files changed, 1115 insertions(+), 379 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index b2b4b5c..6f172ae 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -278,7 +278,6 @@ class MessagesModule { // Detect CONTROL if (attachments.any((a) => a.type == AttachmentType.control)) { isControl = true; - debugPrint('CONTROL detected: ${attachments.where((a) => a.type == AttachmentType.control).first}'); } } } @@ -304,7 +303,7 @@ class MessagesModule { return int.tryParse(value.toString()) ?? 0; } - Future sendMessage( + Future sendMessage( int accountId, int chatId, String text, { @@ -321,7 +320,22 @@ class MessagesModule { 'notify': notify, }; - await _api.sendRequest(Opcode.msgSend, payload); + final response = await _api.sendRequest(Opcode.msgSend, payload); + if (!response.isOk) { + final msg = (response.payload is Map) + ? (response.payload['localizedMessage'] ?? response.payload['message'] ?? 'Ошибка отправки') + : 'Ошибка отправки'; + throw Exception(msg.toString()); + } + final data = response.payload; + if (data is Map) { + final msgMap = data['message']; + if (msgMap is Map) { + final id = msgMap['id']; + if (id != null) return id.toString(); + } + } + return ''; } Future requestTranscription( diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 70a4ae7..6a1277a 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -1,9 +1,28 @@ +import 'package:cached_network_image/cached_network_image.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 '../../../l10n/app_localizations.dart'; +import '../../../core/storage/app_database.dart'; import '../../../main.dart' as main; -import '../../widgets/custom_notification.dart'; + +class _MemberInfo { + final int id; + final bool isAdmin; + final bool isOwner; + final bool isMe; + final int? seenTime; + final bool isOnline; + + const _MemberInfo({ + required this.id, + required this.isAdmin, + required this.isOwner, + required this.isMe, + this.seenTime, + required this.isOnline, + }); +} class ChatInfoScreen extends StatefulWidget { final int chatId; @@ -23,362 +42,965 @@ class ChatInfoScreen extends StatefulWidget { State createState() => _ChatInfoScreenState(); } -class _ChatInfoScreenState extends State - with TickerProviderStateMixin { +class _ChatInfoScreenState extends State { + int _myId = 0; bool _isLoading = true; Map? _chatData; - late AnimationController _shimmerController; + + // DIALOG + int? _otherId; + Map? _contactData; + int? _seenTime; + bool _isOnline = false; + bool _isBot = false; + + bool _infoExpanded = false; + + // CHAT + List<_MemberInfo> _members = []; + int _onlineCount = 0; @override void initState() { super.initState(); - _shimmerController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), - )..repeat(); - _loadChatData(); + _load(); } - @override - void dispose() { - _shimmerController.dispose(); - super.dispose(); - } - - Future _loadChatData() async { - try { - final packet = await main.api.sendRequest(Opcode.chatInfo, { - 'chatIds': [widget.chatId], - }); - - final payload = packet.payload as Map?; - if (payload == null) { - if (mounted) setState(() => _isLoading = false); - return; - } - - final errorField = payload['error']; - if (errorField != null) { - String errorMsg = 'Error'; - if (errorField is Map) { - errorMsg = errorField['localizedMessage'] ?? errorField['message'] ?? errorField.toString(); - } else if (errorField is String) { - errorMsg = errorField; - } - if (mounted) showCustomNotification(context, errorMsg); - setState(() => _isLoading = false); - return; - } - - final chats = payload['chats'] as List?; - if (chats != null && chats.isNotEmpty) { - _chatData = Map.from(chats.first as Map); - } else if (chats != null && chats.isEmpty) { - if (mounted) showCustomNotification(context, 'No data found'); - } + Future _load() async { + 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); - } catch (e) { - if (mounted) { - showCustomNotification(context, 'Error: $e'); - 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.from(chats.first as Map); + + if (widget.chatType == 'DIALOG') { + final parts = _chatData!['participants'] as Map? ?? {}; + for (final key in parts.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null && id != _myId) { + _otherId = id; + break; + } + } + + 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.from(contacts.first as Map); + 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?; + _isOnline = ((p['status'] as int?) ?? 0) > 0; + } + } + } + } else if (widget.chatType == 'CHAT') { + final parts = _chatData!['participants'] as Map? ?? {}; + final admins = _chatData!['adminParticipants'] as Map? ?? {}; + final owner = _chatData!['owner'] as int?; + + final memberIds = []; + for (final k in parts.keys) { + final id = k is int ? k : int.tryParse(k.toString()); + if (id != null) memberIds.add(id); + } + + final Map 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; + } + } + } + } + + _onlineCount = 0; + _members = memberIds.map((id) { + final pres = presenceMap[id]; + final online = ((pres?['status'] as int?) ?? 0) > 0; + if (online) _onlineCount++; + final isAdmin = + admins.containsKey(id.toString()) || admins.containsKey(id); + return _MemberInfo( + id: id, + isAdmin: isAdmin, + isOwner: id == owner, + isMe: id == _myId, + seenTime: pres?['seen'] as int?, + isOnline: online, + ); + }).toList(); + + _members.sort((a, b) { + if (a.isMe != b.isMe) return a.isMe ? -1 : 1; + if (a.isOnline != b.isOnline) return a.isOnline ? -1 : 1; + return (b.seenTime ?? 0).compareTo(a.seenTime ?? 0); + }); + } + + if (mounted) setState(() => _isLoading = false); } + // ─── BUILD ─────────────────────────────────────────────────────────────── + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final l10n = AppLocalizations.of(context); + final isDark = cs.brightness == Brightness.dark; + final bg = isDark ? Colors.black : cs.surface; return Scaffold( - backgroundColor: cs.surface, - appBar: AppBar( - backgroundColor: cs.surface, - elevation: 0, - leading: IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: () => Navigator.pop(context), - ), - title: Text( - l10n?.chatInfoTitle ?? 'Info', - style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), - ), + backgroundColor: bg, + body: SafeArea( + child: _isLoading ? _buildShimmer(cs) : _buildScrollBody(cs), ), - body: _isLoading - ? _buildShimmer(cs) - : _chatData == null - ? Center( - child: Text( - 'No data', - style: TextStyle(color: cs.onSurfaceVariant), - ), - ) - : _buildContent(cs, l10n), ); } - Widget _buildShimmer(ColorScheme cs) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Center( - child: Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - shape: BoxShape.circle, - ), + Widget _buildScrollBody(ColorScheme cs) { + return CustomScrollView( + slivers: [ + SliverAppBar( + backgroundColor: Colors.transparent, + elevation: 0, + floating: true, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), ), - ), - const SizedBox(height: 12), - Center( - child: Container( - width: 120, - height: 20, - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - ), - ), - const SizedBox(height: 24), - ...List.generate( - 10, - (_) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Container( - height: 48, - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(12), + actions: [ + if (widget.chatType == 'DIALOG' && !_isBot) + IconButton( + icon: Icon(Symbols.edit, color: cs.onSurface), + onPressed: () {}, ), - ), - ), + ], ), + SliverToBoxAdapter(child: _buildBody(cs)), ], ); } - Widget _buildContent(ColorScheme cs, AppLocalizations? l10n) { - final chat = _chatData!; - final type = chat['type'] as String? ?? ''; - - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Center( - child: Container( - width: 72, - height: 72, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: cs.primaryContainer, - ), - child: widget.imageUrl.isNotEmpty - ? ClipOval( - child: Image.network(widget.imageUrl, fit: BoxFit.cover), - ) - : Center( - child: Text( - widget.name.isNotEmpty - ? widget.name[0].toUpperCase() - : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 28, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - const SizedBox(height: 12), - Center( - child: Text( + Widget _buildBody(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + const SizedBox(height: 4), + _buildAvatar(cs), + const SizedBox(height: 14), + Text( widget.name, style: TextStyle( color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, + fontSize: 22, + fontWeight: FontWeight.w700, ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + _subtitle(), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + _buildActions(cs), + const SizedBox(height: 16), + LayoutBuilder( + builder: (ctx, constraints) => + _buildInfoArea(cs, constraints.maxWidth), + ), + const SizedBox(height: 80), + ], + ), + ); + } + + // ─── AVATAR ────────────────────────────────────────────────────────────── + + Widget _buildAvatar(ColorScheme cs) { + return Container( + width: 96, + height: 96, + decoration: BoxDecoration(shape: BoxShape.circle, color: cs.primaryContainer), + child: widget.imageUrl.isNotEmpty + ? ClipOval( + child: CachedNetworkImage( + imageUrl: widget.imageUrl, + fit: BoxFit.cover, + errorWidget: (context, error, stack) => _avatarLetters(cs), + ), + ) + : _avatarLetters(cs), + ); + } + + Widget _avatarLetters(ColorScheme cs) => Center( + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 36, + fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 24), + ); - if (type == 'CHANNEL') ...[ - _buildSectionTitle('Channel', cs), - _buildRow( - l10n?.chatInfoSubscribers ?? 'subscribers:', - (chat['participantsCount'] as int?)?.toString() ?? '-', - cs, - ), - if ((chat['link'] as String?)?.isNotEmpty ?? false) - _buildRow( - l10n?.chatInfoLink ?? 'link:', - chat['link'] as String, - cs, - ), - _buildRow( - l10n?.chatInfoOfficial ?? 'official:', - (chat['options']?['OFFICIAL'] as bool?)?.toString() ?? '-', - cs, - ), - _buildRow( - l10n?.chatInfoComments ?? 'comments:', - (chat['options']?['COMMENTS'] as bool?)?.toString() ?? '-', - cs, - ), - _buildRow( - l10n?.chatInfoAplus ?? 'approved by Roskomnadzor:', - (chat['options']?['A_PLUS_CHANNEL'] as bool?)?.toString() ?? '-', - cs, - ), - _buildRow( - l10n?.chatInfoSignAdmin ?? 'admin signature:', - (chat['options']?['SIGN_ADMIN'] as bool?)?.toString() ?? '-', - cs, - ), - if ((chat['modified'] as int?) != null) - _buildRow( - l10n?.chatInfoLastChanged ?? 'last changed:', - _formatTs(chat['modified'] as int), - cs, - ), - if ((chat['created'] as int?) != null) - _buildRow( - l10n?.chatInfoCreated ?? 'created:', - _formatTs(chat['created'] as int), - cs, - ), + // ─── SUBTITLE ──────────────────────────────────────────────────────────── + + String _subtitle() { + switch (widget.chatType) { + case 'DIALOG': + if (_isBot) { + final link = _contactData?['link'] as String?; + final handle = link != null ? '@${Uri.parse(link).pathSegments.last}' : ''; + return '$handle · Бот'.trim(); + } + if (_isOnline) return 'В сети'; + if (_seenTime != null) return _formatLastSeen(_seenTime!); + return ''; + case 'CHAT': + final total = + (_chatData?['participantsCount'] as int?) ?? _members.length; + if (_onlineCount > 0) return '$_onlineCount из $total в сети'; + return _pluralCount(total, 'участник', 'участника', 'участников'); + case 'CHANNEL': + final count = (_chatData?['participantsCount'] as int?) ?? 0; + return _pluralCount(count, 'подписчик', 'подписчика', 'подписчиков'); + default: + return ''; + } + } + + // ─── ACTION BUTTONS ────────────────────────────────────────────────────── + + Widget _buildActions(ColorScheme cs) { + final List<({IconData icon, String label})> btns; + + if (widget.chatType == 'DIALOG') { + if (_isBot) { + btns = [ + (icon: Symbols.chat_bubble, label: 'Цат'), + (icon: Symbols.notifications, label: 'Звук'), + (icon: Symbols.more_horiz, label: 'Ещё'), + ]; + } else { + btns = [ + (icon: Symbols.call, label: 'Звонок'), + (icon: Symbols.videocam, label: 'Видео'), + (icon: Symbols.notifications, label: 'Звук'), + (icon: Symbols.more_horiz, label: 'Ещё'), + ]; + } + } else { + btns = [ + (icon: Symbols.notifications, label: 'Звук'), + (icon: Symbols.search, label: 'Найти'), + (icon: Symbols.more_horiz, label: 'Ещё'), + ]; + } + + return Row( + children: [ + for (int i = 0; i < btns.length; i++) ...[ + _actionBtn(cs, btns[i].icon, btns[i].label), + if (i < btns.length - 1) const SizedBox(width: 8), ], - - if (type == 'CHAT') ...[ - _buildSectionTitle('Chat', cs), - _buildRow( - l10n?.chatInfoMembers ?? 'members:', - (chat['participantsCount'] as int?)?.toString() ?? '-', - cs, - ), - if ((chat['hasBots'] as bool?) ?? false) - _buildRow( - l10n?.chatInfoHasBots ?? 'has bots:', - 'true', - cs, - ), - if ((chat['blockedParticipantsCount'] as int?) != null && - chat['blockedParticipantsCount'] > 0) - _buildRow( - l10n?.chatInfoBlockedCount ?? 'blocked in group:', - (chat['blockedParticipantsCount'] as int).toString(), - cs, - ), - _buildRow( - l10n?.chatInfoOfficialStatus ?? 'official status:', - (chat['options']?['OFFICIAL'] as bool?)?.toString() ?? '-', - cs, - ), - if ((chat['modified'] as int?) != null) - _buildRow( - l10n?.chatInfoLastChanged ?? 'last changed:', - _formatTs(chat['modified'] as int), - cs, - ), - if ((chat['joinTime'] as int?) != null && chat['joinTime'] != 1) - _buildRow( - l10n?.chatInfoJoined ?? 'joined:', - _formatTs(chat['joinTime'] as int), - cs, - ), - if ((chat['created'] as int?) != null) - _buildRow( - l10n?.chatInfoGroupCreated ?? 'group created:', - _formatTs(chat['created'] as int), - cs, - ), - if ((chat['owner'] as int?) != null) - _buildRow( - l10n?.chatInfoGroupOwner ?? 'group owner:', - (chat['owner'] as int).toString(), - cs, - ), - ], - - if (type == 'DIALOG') ...[ - if ((chat['created'] as int?) != null && - chat['created'] != 0 && - chat['created'] != 1) - _buildRow( - l10n?.chatInfoDialogStarted ?? 'dialog started:', - _formatTs(chat['created'] as int), - cs, - ), - ], - - const SizedBox(height: 120), ], ); } - Widget _buildSectionTitle(String title, ColorScheme cs) { - return Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4), - child: Text( - title, - style: TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, + Widget _actionBtn(ColorScheme cs, IconData icon, String label) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: const Color(0xFF007AFF), size: 24), + const SizedBox(height: 5), + Text(label, style: TextStyle(color: cs.onSurface, fontSize: 12)), + ], ), ), ); } - Widget _buildRow(String label, String value, ColorScheme cs) { + // ─── SECTIONS ──────────────────────────────────────────────────────────── + + List _buildSections(ColorScheme cs) { + switch (widget.chatType) { + case 'DIALOG': + return _dialogSections(cs); + case 'CHAT': + return _groupSections(cs); + case 'CHANNEL': + return _channelSections(cs); + default: + return [_attachmentsCard(cs)]; + } + } + + List _dialogSections(ColorScheme cs) { + final result = []; + + if (_isBot) { + final link = _contactData?['link'] as String?; + if (link != null) { + result + ..add(_linkCard(cs, link)) + ..add(const SizedBox(height: 8)); + } + } else { + final phone = _contactData?['phone']; + final phoneInt = phone is int ? phone : int.tryParse(phone?.toString() ?? ''); + if (phoneInt != null && phoneInt > 0) { + result + ..add(_infoCard(cs, 'Номер телефона', _formatPhone(phoneInt))) + ..add(const SizedBox(height: 8)); + } + } + + result.add(_attachmentsCard(cs)); + return result; + } + + List _groupSections(ColorScheme cs) { + return [ + _attachmentsCard(cs), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + 'УЧАСТНИКИ', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ), + Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + _memberAction(cs, Symbols.person_add, 'Добавить участника', () {}), + _listDivider(cs), + _memberAction(cs, Symbols.link, 'Пригласить по ссылке', () {}), + ..._members.expand((m) => [_listDivider(cs), _memberTile(cs, m)]), + ], + ), + ), + ]; + } + + List _channelSections(ColorScheme cs) { + final result = []; + + final link = _chatData?['link'] as String?; + if (link != null) { + result + ..add(_linkCard(cs, link)) + ..add(const SizedBox(height: 8)); + } + + final desc = _chatData?['description'] as String?; + if (desc != null && desc.isNotEmpty) { + result + ..add(_descCard(cs, desc)) + ..add(const SizedBox(height: 8)); + } + + result.add(_attachmentsCard(cs)); + return result; + } + + // ─── CARD WIDGETS ──────────────────────────────────────────────────────── + + Widget _infoCard(ColorScheme cs, String label, String value) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + const SizedBox(height: 4), + Text(value, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500)), + ], + ), + ); + } + + Widget _linkCard(ColorScheme cs, String link) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 14), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Ссылка', + style: + TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + const SizedBox(height: 4), + Text(link, + style: const TextStyle( + color: Color(0xFF007AFF), fontSize: 15)), + ], + ), + ), + IconButton( + icon: const Icon(Symbols.share, + color: Color(0xFF007AFF), size: 22), + onPressed: () {}, + ), + IconButton( + icon: const Icon(Symbols.qr_code, + color: Color(0xFF007AFF), size: 22), + onPressed: () {}, + ), + ], + ), + ); + } + + Widget _descCard(ColorScheme cs, String desc) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Text(desc, + style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4)), + ); + } + + Widget _attachmentsCard(ColorScheme cs) { return Container( - margin: const EdgeInsets.only(bottom: 1), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(Symbols.photo_library, color: cs.onSurfaceVariant, size: 28), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Вложения', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500)), + Text('Фото, видео, файлы и ссылки', + style: + TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + ], + ), + ), + Icon(Symbols.chevron_right, color: cs.onSurfaceVariant), + ], + ), + ); + } + + // ─── MEMBER LIST ───────────────────────────────────────────────────────── + + Widget _memberAction( + ColorScheme cs, IconData icon, String label, VoidCallback onTap) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon(icon, color: const Color(0xFF007AFF), size: 26), + const SizedBox(width: 14), + Text(label, + style: TextStyle(color: cs.onSurface, fontSize: 16)), + ], + ), + ), + ); + } + + Widget _listDivider(ColorScheme cs) => Divider( + height: 1, + indent: 56, + endIndent: 0, + color: cs.outlineVariant.withValues(alpha: 0.3), + ); + + Widget _memberTile(ColorScheme cs, _MemberInfo member) { + final name = + ContactCache.get(member.id) ?? (member.isMe ? 'Вы' : '${member.id}'); + final avatar = ContactCache.getAvatar(member.id); + + final String sublabel; + if (member.isMe) { + sublabel = 'Вы'; + } else if (member.isOnline) { + sublabel = 'В сети'; + } else if (member.seenTime != null) { + sublabel = _formatLastSeen(member.seenTime!); + } else { + sublabel = 'Был(-а) недавно'; + } + + final String? roleLabel = + member.isOwner ? 'владелец' : (member.isAdmin ? 'Адмін' : null); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + (avatar != null && avatar.isNotEmpty) + ? CircleAvatar( + radius: 22, + backgroundImage: CachedNetworkImageProvider(avatar), + backgroundColor: cs.primaryContainer, + ) + : CircleAvatar( + radius: 22, + backgroundColor: cs.primaryContainer, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, fontSize: 16), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500)), + Text(sublabel, + style: TextStyle( + color: cs.onSurfaceVariant, fontSize: 13)), + ], + ), + ), + if (roleLabel != null) + Text(roleLabel, + style: + TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + ], + ), + ); + } + + // ─── INFO AREA ─────────────────────────────────────────────────────────── + + Widget _buildInfoArea(ColorScheme cs, double W) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Кнопка "Инфо" — всегда полная ширина + GestureDetector( + onTap: () => setState(() => _infoExpanded = !_infoExpanded), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(Symbols.info, color: const Color(0xFF007AFF), size: 22), + const SizedBox(width: 12), + Text( + 'Инфо', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + AnimatedRotation( + turns: _infoExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 220), + child: Icon(Symbols.keyboard_arrow_down, + color: cs.onSurfaceVariant), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + // Контент: при закрытии — обычная колонка, при открытии — Row + AnimatedCrossFade( + duration: const Duration(milliseconds: 250), + sizeCurve: Curves.easeOut, + firstCurve: Curves.easeOut, + secondCurve: Curves.easeOut, + crossFadeState: _infoExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _buildSections(cs), + ), + secondChild: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _buildCompactSections(cs)), + const SizedBox(width: 8), + Expanded(child: _buildInfoPanelCard(cs)), + ], + ), + ), + ], + ); + } + + // Компактные карточки — левая колонка при открытом инфо + Widget _buildCompactSections(ColorScheme cs) { + final items = []; + + if (widget.chatType == 'DIALOG') { + if (_isBot) { + final link = _contactData?['link'] as String?; + if (link != null) items.add(_compactCard(cs, 'Ссылка', link)); + } else { + final phone = _contactData?['phone']; + final phoneInt = + phone is int ? phone : int.tryParse(phone?.toString() ?? ''); + if (phoneInt != null && phoneInt > 0) { + items.add(_compactCard(cs, 'Телефон', _formatPhone(phoneInt))); + } + } + } + + if (widget.chatType == 'CHANNEL') { + final desc = _chatData?['description'] as String?; + if (desc != null && desc.isNotEmpty) { + items.add(_compactCard( + cs, + 'Описание', + desc.length > 80 ? '${desc.substring(0, 80)}…' : desc, + )); + } + } + + if (widget.chatType == 'CHAT') { + final total = (_chatData?['participantsCount'] as int?) ?? _members.length; + items.add(_compactCard(cs, 'Участников', '$total')); + } + + items.add(const SizedBox(height: 8)); + items.add(_compactAttachments(cs)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (int i = 0; i < items.length; i++) ...[ + items[i], + if (i < items.length - 1 && items[i] is! SizedBox) + const SizedBox(height: 8), + ], + ], + ); + } + + Widget _compactCard(ColorScheme cs, String label, String value) { + return Container( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11)), + const SizedBox(height: 3), + Text(value, + style: TextStyle( + color: cs.onSurface, + fontSize: 13, + fontWeight: FontWeight.w500), + maxLines: 4, + overflow: TextOverflow.ellipsis), + ], + ), + ); + } + + Widget _compactAttachments(ColorScheme cs) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), decoration: BoxDecoration( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ + Icon(Symbols.photo_library, color: cs.onSurfaceVariant, size: 20), + const SizedBox(width: 8), Expanded( - flex: 2, - child: Text( - label, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - flex: 3, - child: Text( - value, - style: TextStyle( - color: cs.onSurface, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.end, - ), + child: Text('Вложения', + style: TextStyle( + color: cs.onSurface, + fontSize: 13, + fontWeight: FontWeight.w500)), ), + Icon(Symbols.chevron_right, color: cs.onSurfaceVariant, size: 18), ], ), ); } - String _formatTs(int ts) { - if (ts < 1000000000000) return ts.toString(); - final dt = DateTime.fromMillisecondsSinceEpoch(ts); - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + // Инфо-панель — правая колонка при открытом инфо + Widget _buildInfoPanelCard(ColorScheme cs) { + return Container( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: _buildAllInfoRows(cs), + ); } -} \ No newline at end of file + + Widget _buildAllInfoRows(ColorScheme cs) { + final rows = <({String label, String value})>[]; + final chat = _chatData; + if (chat == null) { + return Text('Нет данных', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)); + } + + void add(String label, dynamic val, {bool tsFormat = false}) { + if (val == null) return; + if (val is bool && !val) return; + String str; + if (tsFormat && val is int && val > 1) { + str = _formatTs(val); + } else if (val is bool) { + str = 'да'; + } else { + str = val.toString(); + } + if (str.isEmpty) return; + rows.add((label: label, value: str)); + } + + final type = widget.chatType; + add('ID чата', chat['id']); + + if (type == 'DIALOG') { + add('Создан', chat['created'], tsFormat: true); + add('Изменён', chat['modified'], tsFormat: true); + add('Статус', chat['status']); + } + + if (type == 'CHAT') { + add('Участников', chat['participantsCount']); + final owner = chat['owner'] as int?; + if (owner != null && owner != 0) { + add('Владелец', ContactCache.get(owner) ?? '$owner'); + } + add('Создана', chat['created'], tsFormat: true); + add('Вступил', (chat['joinTime'] as int?) != null && (chat['joinTime'] as int) > 1 + ? chat['joinTime'] : null, tsFormat: true); + add('Изменена', chat['modified'], tsFormat: true); + add('Есть боты', chat['hasBots'] as bool?); + final blocked = chat['blockedParticipantsCount'] as int?; + if (blocked != null && blocked > 0) add('Заблокировано', blocked); + final opts = chat['options'] as Map?; + add('Официальная', opts?['OFFICIAL'] as bool?); + add('Подпись адм.', opts?['SIGN_ADMIN'] as bool?); + add('Статус', chat['status']); + } + + if (type == 'CHANNEL') { + add('Подписчиков', chat['participantsCount']); + add('Создан', chat['created'], tsFormat: true); + add('Изменён', chat['modified'], tsFormat: true); + final opts = chat['options'] as Map?; + add('Официальный', opts?['OFFICIAL'] as bool?); + add('Комментарии', opts?['COMMENTS'] as bool?); + add('РКН', opts?['A_PLUS_CHANNEL'] as bool?); + add('Подпись адм.', opts?['SIGN_ADMIN'] as bool?); + add('Только адм.', opts?['ONLY_ADMIN_CAN_ADD_MEMBER'] as bool?); + add('Статус', chat['status']); + } + + if (rows.isEmpty) { + return Text('Нет данных', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < rows.length; i++) ...[ + _infoRow(cs, rows[i].label, rows[i].value), + if (i < rows.length - 1) + Divider( + height: 10, + color: cs.outlineVariant.withValues(alpha: 0.25)), + ], + ], + ); + } + + Widget _infoRow(ColorScheme cs, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10)), + Text(value, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w500)), + ], + ), + ); + } + + // ─── SHIMMER ───────────────────────────────────────────────────────────── + + Widget _buildShimmer(ColorScheme cs) { + Widget block(double w, double h, {double r = 8}) => Container( + width: w, + height: h, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(r), + ), + ); + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 60, 16, 0), + children: [ + Center( + child: block(96, 96, r: 48)), + const SizedBox(height: 14), + Center(child: block(160, 22, r: 8)), + const SizedBox(height: 8), + Center(child: block(110, 16, r: 6)), + const SizedBox(height: 24), + block(double.infinity, 70, r: 14), + const SizedBox(height: 12), + block(double.infinity, 70, r: 14), + const SizedBox(height: 12), + block(double.infinity, 70, r: 14), + ], + ); + } + + // ─── HELPERS ───────────────────────────────────────────────────────────── + + String _formatLastSeen(int ms) { + final diff = DateTime.now().millisecondsSinceEpoch - ms; + if (diff < 60000) return 'только что'; + if (diff < 3600000) return '${diff ~/ 60000} мин назад'; + if (diff < 86400000) return '${diff ~/ 3600000} ч назад'; + if (diff < 604800000) return '${diff ~/ 86400000} д назад'; + return 'давно'; + } + + String _formatPhone(int phone) { + final s = phone.toString(); + if (s.length == 11 && s.startsWith('7')) { + return '+7 ${s.substring(1, 4)} ${s.substring(4, 7)}-' + '${s.substring(7, 9)}-${s.substring(9, 11)}'; + } + return '+$s'; + } + + String _formatTs(int ts) { + final dt = DateTime.fromMillisecondsSinceEpoch(ts); + return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } + + String _pluralCount(int n, String one, String few, String many) { + final mod100 = n % 100; + final mod10 = n % 10; + if (mod100 >= 11 && mod100 <= 14) return '$n $many'; + if (mod10 == 1) return '$n $one'; + if (mod10 >= 2 && mod10 <= 4) return '$n $few'; + return '$n $many'; + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 906dadc..13cf4f7 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -787,7 +787,7 @@ class _ChatListScreenState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 2), + padding: const EdgeInsets.fromLTRB(20, 6, 20, 3), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -933,7 +933,7 @@ class _ChatListScreenState extends State ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), + padding: const EdgeInsets.fromLTRB(20, 3, 20, 4), child: Container( height: 44, decoration: BoxDecoration( diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index e37aca3..e6c7c8c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:komet/backend/modules/chats.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -51,7 +52,6 @@ class _ChatScreenState extends State final GlobalKey _listKey = GlobalKey(); bool _hasText = false; bool _isLoading = true; - bool _isSending = false; bool _showAttachmentPanel = false; late AnimationController _shimmerController; List _messages = []; @@ -64,6 +64,7 @@ class _ChatScreenState extends State late final AnimationController _floatingDateAnimController; final Map _separatorKeys = {}; double _lastScrollOffset = 0; + String? _lastSentId; @override void initState() { @@ -87,10 +88,10 @@ class _ChatScreenState extends State final activeProfile = await AppDatabase.loadActiveProfile(); _myId = activeProfile?.id ?? 0; ChatsModule.getChat(_myId, widget.chatId).then((value) { - chat = value[0]; - }).catchError((error) { - - }); + if (mounted && value.isNotEmpty) { + setState(() { chat = value.first; }); + } + }).catchError((_) {}); final cachedRows = await AppDatabase.loadMessages( _myId, @@ -155,17 +156,29 @@ class _ChatScreenState extends State } } + String? _effectiveStatus(CachedMessage msg) { + if (msg.senderId != _myId) return null; + if (msg.status == 'sending' || msg.status == 'error') return msg.status; + final c = chat; + if (c == null) return 'sent'; + int otherReadTime = 0; + for (final entry in c.participants.entries) { + if (entry.key != _myId && entry.value > otherReadTime) { + otherReadTime = entry.value; + } + } + if (otherReadTime > 0 && otherReadTime >= msg.time) return 'read'; + return 'sent'; + } + Future _sendMessage() async { final text = _messageController.text.trim(); if (text.isEmpty || _myId == 0) return; - setState(() { - _isSending = true; - }); + final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; + final now = DateTime.now().millisecondsSinceEpoch; try { - final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; - final now = DateTime.now().millisecondsSinceEpoch; final tempMessage = CachedMessage( id: tempId, @@ -178,6 +191,7 @@ class _ChatScreenState extends State ); setState(() { + _lastSentId = tempId; _messages.add(tempMessage); _messageController.clear(); _hasText = false; @@ -189,13 +203,13 @@ class _ChatScreenState extends State _scrollToBottom(); - await messagesModule.sendMessage(_myId, widget.chatId, text); + final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text); final index = _messages.indexWhere((m) => m.id == tempId); - if (index != -1) { + if (index != -1 && mounted) { setState(() { _messages[index] = CachedMessage( - id: tempId, + id: actualId.isNotEmpty ? actualId : tempId, accountId: _myId, chatId: widget.chatId, senderId: _myId, @@ -206,12 +220,21 @@ class _ChatScreenState extends State }); } } catch (e) { - debugPrint('Error sending message: $e'); Haptics.error(); - } finally { - setState(() { - _isSending = false; - }); + final index = _messages.indexWhere((m) => m.id == tempId); + if (index != -1 && mounted) { + setState(() { + _messages[index] = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: 'error', + ); + }); + } } } @@ -429,7 +452,7 @@ class _ChatScreenState extends State // TODO: Локализация // TODO: Cклонения - String? status = chat?.type == "CHAT" ? "${chat?.participants.length.toString()} участников" : "last seen recently"; + final String status = chat?.type == "CHAT" ? "${chat?.participants.length ?? 0} участников" : "last seen recently"; return Scaffold( backgroundColor: cs.surface, appBar: PreferredSize( @@ -505,7 +528,7 @@ class _ChatScreenState extends State ], ), Text( - status ?? "", + status, style: TextStyle( color: cs.onSurfaceVariant, fontSize: 12, @@ -590,8 +613,6 @@ class _ChatScreenState extends State final msgItem = item as _MessageItem; final message = msgItem.message; final msgIndex = msgItem.index; - debugPrint( - 'LIST_ITEM: ${message.id} isControl=${message.isControl} hasAttach=${message.attachments != null}'); final isMe = message.senderId == _myId; final prevMessage = msgIndex > 0 ? _messages[msgIndex - 1] : null; @@ -599,14 +620,26 @@ class _ChatScreenState extends State ? _messages[msgIndex + 1] : null; - return MessageBubble( + final bubble = MessageBubble( message: message, isMe: isMe, myId: _myId, prevMessage: prevMessage, nextMessage: nextMessage, chatType: chat?.type ?? 'CHAT', + overrideStatus: _effectiveStatus(message), ); + + if (isMe && message.id == _lastSentId) { + return _SentMessageAnimation( + key: ValueKey('anim_${message.id}'), + onComplete: () { + if (mounted) setState(() => _lastSentId = null); + }, + child: bubble, + ); + } + return bubble; }, ), if (_lastFloatingDate != null) @@ -805,22 +838,33 @@ class _ChatScreenState extends State Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400), const SizedBox(width: 12), Expanded( - child: TextField( - controller: _messageController, - style: TextStyle(color: cs.onSurface, fontSize: 16), - maxLines: null, - keyboardType: TextInputType.multiline, - textAlignVertical: TextAlignVertical.center, - decoration: InputDecoration( - hintText: 'Message', - hintStyle: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 16, - ), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - vertical: 14, + child: Focus( + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.enter && + !HardwareKeyboard.instance.isShiftPressed) { + if (_hasText) _sendMessage(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: TextField( + controller: _messageController, + style: TextStyle(color: cs.onSurface, fontSize: 16), + maxLines: null, + keyboardType: TextInputType.multiline, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + hintText: 'Message', + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + vertical: 14, + ), ), ), ), @@ -892,3 +936,59 @@ class _ChatScreenState extends State ); } } + +class _SentMessageAnimation extends StatefulWidget { + final Widget child; + final VoidCallback onComplete; + + const _SentMessageAnimation({ + super.key, + required this.child, + required this.onComplete, + }); + + @override + State<_SentMessageAnimation> createState() => _SentMessageAnimationState(); +} + +class _SentMessageAnimationState extends State<_SentMessageAnimation> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _opacity; + late final Animation _slide; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + ); + _opacity = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut); + _slide = Tween(begin: 16, end: 0).animate( + CurvedAnimation(parent: _ctrl, curve: Curves.easeOut), + ); + _ctrl.forward().whenComplete(widget.onComplete); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (context, child) => Opacity( + opacity: _opacity.value, + child: Transform.translate( + offset: Offset(0, _slide.value), + child: child, + ), + ), + child: widget.child, + ); + } +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index fdbe793..c723f53 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -44,6 +44,7 @@ class MessageBubble extends StatelessWidget { final CachedMessage? prevMessage; final CachedMessage? nextMessage; final String chatType; + final String? overrideStatus; const MessageBubble({ super.key, @@ -52,7 +53,8 @@ class MessageBubble extends StatelessWidget { required this.myId, this.prevMessage, this.nextMessage, - required this.chatType + required this.chatType, + this.overrideStatus, }); bool get isGroupedWithNext { @@ -315,7 +317,6 @@ class MessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { if (message.isControl) { - debugPrint('BUILD CONTROL: ${message.id}'); return Padding( padding: EdgeInsets.only(top: topMargin, bottom: bottomMargin), child: Center(child: _buildControlContent(context)), @@ -490,7 +491,7 @@ class MessageBubble extends StatelessWidget { children: [ Flexible( child: isForwarded - ? _buildForwardedInlineText(context, forwarded, textColor) + ? _buildForwardedInlineText(context, forwarded!, textColor) : Text( message.text ?? '', style: TextStyle(color: textColor, fontSize: 16, height: 1.3), @@ -500,7 +501,9 @@ class MessageBubble extends StatelessWidget { Padding( padding: const EdgeInsets.only(bottom: 2), child: Text( - _formatTime(message.time), + message.status == 'EDITED' + ? '${_formatTime(message.time)} ред.' + : _formatTime(message.time), style: TextStyle( color: textColor.withValues(alpha: 0.7), fontSize: 10, @@ -904,13 +907,6 @@ class MessageBubble extends StatelessWidget { ForwardedMessageAttachment forwarded, List attachments, ) { - debugPrint('DEBUG: _buildForwardedGenericContent called'); - debugPrint('DEBUG: attachments count: ${attachments.length}'); - for (var i = 0; i < attachments.length; i++) { - debugPrint('DEBUG: attachment[$i] type: ${attachments[i].runtimeType}'); - debugPrint('DEBUG: attachment[$i] type field: ${attachments[i].type}'); - } - final cs = Theme.of(context).colorScheme; final headerColor = isMe ? Colors.white.withValues(alpha: 0.7) @@ -1671,7 +1667,7 @@ class MessageBubble extends StatelessWidget { url: url, textColor: textColor, isMe: isMe, - status: message.status, + status: overrideStatus ?? message.status, time: message.time, cs: cs, waveData: waveData, @@ -1729,31 +1725,31 @@ class MessageBubble extends StatelessWidget { } Widget _buildStatusIcon(BuildContext context) { - final status = message.status; + final status = overrideStatus ?? message.status; IconData icon; Color color; - if (status == null || status == 'sending' || status == 'pending') { - icon = Symbols.check; - color = Colors.white70; - } else { - switch (status) { - case 'sent': - icon = Symbols.check; - color = Colors.white70; - case 'delivered': - icon = Symbols.done_all; - color = Colors.white70; - case 'read': - icon = Symbols.done_all; - color = const Color(0xFF34C759); - case 'error': - icon = Symbols.error; - color = Colors.redAccent; - default: - icon = Symbols.check; - color = Colors.white70; - } + switch (status) { + case 'sending': + case 'pending': + icon = Symbols.schedule; + color = Colors.white70; + case null: + case 'sent': + icon = Symbols.check; + color = Colors.white70; + case 'delivered': + icon = Symbols.done_all; + color = Colors.white70; + case 'read': + icon = Symbols.done_all; + color = const Color(0xFF4FC3F7); + case 'error': + icon = Symbols.error; + color = Colors.redAccent; + default: + icon = Symbols.check; + color = Colors.white70; } return Icon(icon, size: 14, color: color); @@ -1840,11 +1836,15 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { IconData icon; Color color; - if (status == null || status == 'sending' || status == 'pending') { + if (status == null || status == 'sent') { icon = Symbols.check; color = Colors.white54; } else { switch (status) { + case 'sending': + case 'pending': + icon = Symbols.schedule; + color = Colors.white54; case 'sent': icon = Symbols.check; color = Colors.white54; @@ -1853,7 +1853,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { color = Colors.white54; case 'read': icon = Symbols.done_all; - color = const Color(0xFF34C759); + color = const Color(0xFF4FC3F7); case 'error': icon = Symbols.error; color = Colors.redAccent; From 849df837202e1fab059c73a66f88dc9180b51088 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 15 May 2026 19:14:29 +0700 Subject: [PATCH 23/43] =?UTF-8?q?=D0=B3=D0=B5=D0=BF=D1=8B(=D0=B2=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5,=20=D1=85=D0=B7),=20=D0=BF=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D0=BB=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D1=83=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D1=81=D1=82=D0=B8,?= =?UTF-8?q?=20=D0=BC=D0=B5=D0=BD=D1=8E=D1=88=D0=BA=D1=83=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B8=20=D1=82=D0=B0=D0=BC=20=D0=B1?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B8=D0=B4=D0=B8=20=D0=BD=D0=B0=D1=85=D1=83?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 11 ++++++++--- lib/frontend/screens/chats/chat_list_screen.dart | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 1a983be..e78828a 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -375,9 +375,7 @@ class AccountModule { final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { final saved = await AppDatabase.getPrivacyConfig(accountId); - if (saved != null) { - return PrivacyConfig.fromJson(saved); - } + if (saved != null) return PrivacyConfig.fromJson(saved); } return PrivacyConfig.empty(); } @@ -1021,6 +1019,13 @@ class AccountModule { profile.id, config.cast(), ); + final userConfig = config['user']; + if (userConfig is Map) { + await AppDatabase.savePrivacyConfig( + profile.id, + jsonEncode(userConfig), + ); + } } try { await FoldersModule.syncFromServer(_api, profile.id); diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 13cf4f7..db596fd 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -933,7 +933,7 @@ class _ChatListScreenState extends State ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 3, 20, 4), + padding: const EdgeInsets.fromLTRB(20, 3, 20, 6), child: Container( height: 44, decoration: BoxDecoration( @@ -1078,6 +1078,7 @@ class _ChatListScreenState extends State parent: const AlwaysScrollableScrollPhysics(), ), slivers: [ + const SliverToBoxAdapter(child: SizedBox(height: 6)), if (chats.isEmpty && !_isInitialLoading) SliverFillRemaining( child: Center( From 52f74a51b5fa88142d4763b4b87522f5147b47d1 Mon Sep 17 00:00:00 2001 From: noxzion Date: Fri, 15 May 2026 17:51:01 +0500 Subject: [PATCH 24/43] =?UTF-8?q?=D0=B3=D0=BE=D0=B2=D0=BD=D0=BE=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 78 ++++++++++--------- pubspec.lock | 8 +- 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index db596fd..d716833 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -933,7 +933,7 @@ class _ChatListScreenState extends State ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 3, 20, 6), + padding: const EdgeInsets.fromLTRB(20, 3, 20, 14), child: Container( height: 44, decoration: BoxDecoration( @@ -1078,7 +1078,7 @@ class _ChatListScreenState extends State parent: const AlwaysScrollableScrollPhysics(), ), slivers: [ - const SliverToBoxAdapter(child: SizedBox(height: 6)), + const SliverToBoxAdapter(child: SizedBox(height: 14)), if (chats.isEmpty && !_isInitialLoading) SliverFillRemaining( child: Center( @@ -1104,7 +1104,7 @@ class _ChatListScreenState extends State // Insert separator row between pinned and regular sections if (hasSeparator && index == pinnedCount) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 20), child: Divider( height: 1, thickness: 0.5, @@ -1823,7 +1823,7 @@ Navigator.push( ? cs.primary.withValues(alpha: 0.08) : Colors.transparent, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -2073,13 +2073,27 @@ Navigator.push( } Widget _buildFabMenu() { + final cs = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _buildFabMenuItem(Symbols.group_add, 'Создать группу'), + const SizedBox(height: 4), + _buildFabMenuItem(Symbols.campaign, 'Создать канал'), + const SizedBox(height: 4), + _buildFabMenuItem(Symbols.person_add, 'Создать контакт'), + ], + ); + } + + Widget _buildFabMenuItem(IconData icon, String title) { final cs = Theme.of(context).colorScheme; return Container( width: 220, - padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(100), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.2), @@ -2088,39 +2102,27 @@ Navigator.push( ), ], ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFabMenuItem(Symbols.search, 'Найти по номеру'), - _buildFabMenuItem(Symbols.group_add, 'Добавить группу'), - _buildFabMenuItem(Symbols.campaign, 'Создать канал'), - ], - ), - ); - } - - Widget _buildFabMenuItem(IconData icon, String title) { - final cs = Theme.of(context).colorScheme; - return InkWell( - onTap: () { - // Action logic here - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - Icon(icon, color: cs.onSurface, size: 22), - const SizedBox(width: 12), - Text( - title, - style: TextStyle( - color: cs.onSurface, - fontSize: 14, - fontWeight: FontWeight.w500, + child: InkWell( + onTap: () { + // Action logic here + }, + borderRadius: BorderRadius.circular(100), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(icon, color: cs.onSurface, size: 22), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), ), - ), - ], + ], + ), ), ), ); diff --git a/pubspec.lock b/pubspec.lock index 00c6969..13dcd14 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -385,10 +385,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -726,10 +726,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.9" timezone: dependency: "direct main" description: From 5a6cb10c91ed0d8c05158afe5dd0177e0decd71f Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 15 May 2026 18:14:22 +0300 Subject: [PATCH 25/43] =?UTF-8?q?feat(push):=20FCM=20via=20MAX=20server=20?= =?UTF-8?q?=E2=80=94=20oneme=20flavor,=20dedicated=20FCM=20workflow,=20opc?= =?UTF-8?q?ode=2022,=20notifications=20+=20CI/lint=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-android-fcm.yml | 91 +++++++++ .github/workflows/build-android.yml | 18 +- .github/workflows/flutter-dev.yml | 17 +- .github/workflows/flutter-main.yml | 78 +++++++- android/app/build.gradle.kts | 20 +- android/app/src/komet/google-services.json | 29 +++ android/app/src/main/AndroidManifest.xml | 4 + android/app/src/oneme/google-services.json | 29 +++ android/settings.gradle.kts | 1 + lib/backend/modules/account.dart | 33 +++ lib/core/push/push_service.dart | 188 ++++++++++++++++++ .../screens/chats/chat_list_screen.dart | 7 +- lib/frontend/screens/chats/chat_screen.dart | 1 - .../screens/profile/edit_profile_screen.dart | 3 +- .../profile/password_entry_screen.dart | 2 - .../screens/profile/security_screen.dart | 2 +- lib/frontend/widgets/message_bubble.dart | 178 ++--------------- lib/main.dart | 18 ++ pubspec.lock | 128 +++++++++++- pubspec.yaml | 3 + 20 files changed, 647 insertions(+), 203 deletions(-) create mode 100644 .github/workflows/build-android-fcm.yml create mode 100644 android/app/src/komet/google-services.json create mode 100644 android/app/src/oneme/google-services.json create mode 100644 lib/core/push/push_service.dart diff --git a/.github/workflows/build-android-fcm.yml b/.github/workflows/build-android-fcm.yml new file mode 100644 index 0000000..b603622 --- /dev/null +++ b/.github/workflows/build-android-fcm.yml @@ -0,0 +1,91 @@ +name: Build Android (FCM) + +on: + workflow_dispatch: + push: + branches: + - main + - master + paths: + - 'lib/**' + - 'android/**' + - 'pubspec.yaml' + - '.github/workflows/build-android-fcm.yml' + pull_request: + paths: + - 'lib/**' + - 'android/**' + - 'pubspec.yaml' + - '.github/workflows/build-android-fcm.yml' + +jobs: + build-android-fcm: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v3 + + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.5' + channel: 'stable' + + - name: Get dependencies + run: flutter pub get + + - name: Configure Gradle + run: | + mkdir -p ~/.gradle + echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties + echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties + + - name: Build Universal APK + run: flutter build apk --release --flavor oneme + + - name: Build Split APKs + run: flutter build apk --release --split-per-abi --flavor oneme + + - name: Upload Universal APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-fcm-universal + path: build/app/outputs/flutter-apk/app-oneme-release.apk + retention-days: 30 + + - name: Upload arm64-v8a APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-fcm-arm64-v8a + path: build/app/outputs/flutter-apk/app-oneme-arm64-v8a-release.apk + retention-days: 30 + + - name: Upload armeabi-v7a APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-fcm-armeabi-v7a + path: build/app/outputs/flutter-apk/app-oneme-armeabi-v7a-release.apk + retention-days: 30 + + - name: Upload x86_64 APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-fcm-x86_64 + path: build/app/outputs/flutter-apk/app-oneme-x86_64-release.apk + retention-days: 30 + + - name: Build App Bundle + run: flutter build appbundle --release --flavor oneme + + - name: Upload App Bundle artifact + uses: actions/upload-artifact@v4 + with: + name: komet-android-fcm-aab + path: build/app/outputs/bundle/onemeRelease/app-oneme-release.aab + retention-days: 30 diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 380cd38..4866fe6 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -45,47 +45,47 @@ jobs: mkdir -p ~/.gradle echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties - + - name: Build Universal APK - run: flutter build apk --release + run: flutter build apk --release --flavor komet - name: Build Split APKs - run: flutter build apk --release --split-per-abi + run: flutter build apk --release --split-per-abi --flavor komet - name: Upload Universal APK uses: actions/upload-artifact@v4 with: name: komet-android-universal - path: build/app/outputs/flutter-apk/app-release.apk + path: build/app/outputs/flutter-apk/app-komet-release.apk retention-days: 30 - name: Upload arm64-v8a APK uses: actions/upload-artifact@v4 with: name: komet-android-arm64-v8a - path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk + path: build/app/outputs/flutter-apk/app-komet-arm64-v8a-release.apk retention-days: 30 - name: Upload armeabi-v7a APK uses: actions/upload-artifact@v4 with: name: komet-android-armeabi-v7a - path: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk + path: build/app/outputs/flutter-apk/app-komet-armeabi-v7a-release.apk retention-days: 30 - name: Upload x86_64 APK uses: actions/upload-artifact@v4 with: name: komet-android-x86_64 - path: build/app/outputs/flutter-apk/app-x86_64-release.apk + path: build/app/outputs/flutter-apk/app-komet-x86_64-release.apk retention-days: 30 - name: Build App Bundle - run: flutter build appbundle --release + run: flutter build appbundle --release --flavor komet - name: Upload App Bundle artifact uses: actions/upload-artifact@v4 with: name: komet-android-aab - path: build/app/outputs/bundle/release/app-release.aab + path: build/app/outputs/bundle/kometRelease/app-komet-release.aab retention-days: 30 diff --git a/.github/workflows/flutter-dev.yml b/.github/workflows/flutter-dev.yml index a1dc487..7e5d633 100644 --- a/.github/workflows/flutter-dev.yml +++ b/.github/workflows/flutter-dev.yml @@ -13,10 +13,17 @@ jobs: - name: Checkout code uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: 'stable' + flutter-version: '3.41.5' + channel: 'stable' - name: Install dependencies run: flutter pub get @@ -24,5 +31,11 @@ jobs: - name: Flutter analyze run: flutter analyze + - name: Configure Gradle + run: | + mkdir -p ~/.gradle + echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties + echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties + - name: Build Android APK - run: flutter build apk --release + run: flutter build apk --release --flavor komet diff --git a/.github/workflows/flutter-main.yml b/.github/workflows/flutter-main.yml index 3255616..f43dd96 100644 --- a/.github/workflows/flutter-main.yml +++ b/.github/workflows/flutter-main.yml @@ -6,17 +6,23 @@ on: - 'main' jobs: - analyze-and-build-all: + android: runs-on: ubuntu-latest - steps: - name: Checkout code uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: 'stable' + flutter-version: '3.41.5' + channel: 'stable' - name: Install dependencies run: flutter pub get @@ -24,8 +30,34 @@ jobs: - name: Flutter analyze run: flutter analyze + - name: Configure Gradle + run: | + mkdir -p ~/.gradle + echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties + echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties + - name: Build Android APK - run: flutter build apk --release + run: flutter build apk --release --flavor komet + + web-linux: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.5' + channel: 'stable' + + - name: Install Linux desktop dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev + + - name: Install dependencies + run: flutter pub get - name: Build Web run: flutter build web --release @@ -33,13 +65,41 @@ jobs: - name: Build Linux run: flutter build linux --release + windows: + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.5' + channel: 'stable' + + - name: Install dependencies + run: flutter pub get + - name: Build Windows run: flutter build windows --release - - name: Build iOS (only macOS runners) - if: runner.os == 'macOS' - run: flutter build ios --release + apple: + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 - - name: Build macOS (only macOS runners) - if: runner.os == 'macOS' + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.5' + channel: 'stable' + + - name: Install dependencies + run: flutter pub get + + - name: Build iOS + run: flutter build ios --release --no-codesign + + - name: Build macOS run: flutter build macos --release diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1e361bc..fa4342c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") + id("com.google.gms.google-services") } android { @@ -20,16 +21,27 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "ru.komet.app" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = maxOf(flutter.minSdkVersion, 23) targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } + flavorDimensions += "distribution" + + productFlavors { + create("komet") { + dimension = "distribution" + isDefault = true + applicationId = "ru.komet.app" + } + create("oneme") { + dimension = "distribution" + applicationId = "ru.oneme.app" + } + } + buildTypes { release { // TODO: Add your own signing config for the release build. diff --git a/android/app/src/komet/google-services.json b/android/app/src/komet/google-services.json new file mode 100644 index 0000000..9027e06 --- /dev/null +++ b/android/app/src/komet/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "659634599081", + "project_id": "max-messenger-app", + "storage_bucket": "max-messenger-app.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:659634599081:android:00000000000000000000ab", + "android_client_info": { + "package_name": "ru.komet.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyABuDYeeDXIOrKTXLkUj30Ii143ofPe63Q" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b2c538d..8dd5624 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + +