изменена логика показа папок, добавлена кнопка создания папок, отображание от кого переслано сообщение, предпросмотр сообщений по зажатии на аватарке

This commit is contained in:
needle10
2025-11-15 23:39:22 +03:00
parent de067e77df
commit dad8b31cf2
12 changed files with 1122 additions and 612 deletions

View File

@@ -5,5 +5,11 @@
## How to build?
### This is app built on flutter, use flutter guide
## How to countibute?
### Join the dev team
### Create a fork, do everything
### And create pull requeste
### Make sure your commits looks like:
<code>fix: something went worng when user...</code>
<code>add: search by id</code>
<code>edit: refactored something</code>
<code>Other actions should marked as "other:" and discribes what you did</code>

BIN
assets/images/spermum.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,6 @@ import 'package:video_player/video_player.dart';
bool _debugShowExactDate = false;
void toggleDebugExactDate() {
_debugShowExactDate = !_debugShowExactDate;
print('Debug режим точной даты: $_debugShowExactDate');
@@ -88,13 +87,12 @@ class _ChatScreenState extends State<ChatScreen> {
ItemPositionsListener.create();
final ValueNotifier<bool> _showScrollToBottomNotifier = ValueNotifier(false);
late Contact _currentContact;
Message? _replyingToMessage;
final Map<int, Contact> _contactDetailsCache = {};
final Set<int> _loadingContactIds = {};
final Map<String, String> _lastReadMessageIdByParticipant = {};
@@ -143,6 +141,30 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
Future<void> _loadContactIfNeeded(int contactId) async {
if (_contactDetailsCache.containsKey(contactId) ||
_loadingContactIds.contains(contactId)) {
return;
}
_loadingContactIds.add(contactId);
try {
final contacts = await ApiService.instance.fetchContactsByIds([
contactId,
]);
if (contacts.isNotEmpty && mounted) {
final contact = contacts.first;
_contactDetailsCache[contact.id] = contact;
setState(() {});
}
} catch (e) {
print('Ошибка загрузки контакта $contactId: $e');
} finally {
_loadingContactIds.remove(contactId);
}
}
@override
void initState() {
super.initState();
@@ -375,7 +397,6 @@ class _ChatScreenState extends State<ChatScreen> {
if (!mounted) return;
print("✅ Получено ${allMessages.length} сообщений с сервера.");
final Set<int> senderIds = {};
for (final message in allMessages) {
senderIds.add(message.senderId);
@@ -389,7 +410,6 @@ class _ChatScreenState extends State<ChatScreen> {
}
senderIds.remove(0); // Удаляем системный ID, если он есть
final idsToFetch = senderIds
.where((id) => !_contactDetailsCache.containsKey(id))
.toList();
@@ -475,8 +495,6 @@ class _ChatScreenState extends State<ChatScreen> {
_buildChatItems();
_isLoadingMore = false;
setState(() {});
}
bool _isSameDay(DateTime date1, DateTime date2) {
@@ -532,12 +550,10 @@ class _ChatScreenState extends State<ChatScreen> {
print('DEBUG GROUPING: isGrouped=$isGrouped');
}
final isFirstInGroup =
previousMessage == null ||
!_isMessageGrouped(currentMessage, previousMessage);
final isLastInGroup =
i == source.length - 1 ||
!_isMessageGrouped(source[i + 1], currentMessage);
@@ -1155,7 +1171,6 @@ class _ChatScreenState extends State<ChatScreen> {
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) {
Navigator.of(context).pop();
widget.onChatUpdated?.call();
@@ -1213,11 +1228,9 @@ class _ChatScreenState extends State<ChatScreen> {
onPressed: () {
Navigator.of(context).pop(); // Закрываем диалог подтверждения
try {
ApiService.instance.leaveGroup(widget.chatId);
if (mounted) {
Navigator.of(context).pop();
widget.onChatUpdated?.call();
@@ -1388,30 +1401,43 @@ class _ChatScreenState extends State<ChatScreen> {
if (isMe) {
final messageId = item.message.id;
if (messageId.startsWith('local_')) {
readStatus = MessageReadStatus.sending;
} else {
readStatus = MessageReadStatus.sent;
}
}
String? forwardedFrom;
String? forwardedFromAvatarUrl;
if (message.isForwarded) {
final originalSenderId =
message.link?['message']?['sender'] as int?;
if (originalSenderId != null) {}
final link = message.link;
if (link is Map<String, dynamic>) {
final chatName = link['chatName'] as String?;
final chatIconUrl = link['chatIconUrl'] as String?;
if (chatName != null) {
forwardedFrom = chatName;
forwardedFromAvatarUrl = chatIconUrl;
} else {
final forwardedMessage =
link['message'] as Map<String, dynamic>?;
final originalSenderId =
forwardedMessage?['sender'] as int?;
if (originalSenderId != null) {
final originalSenderContact =
_contactDetailsCache[originalSenderId];
if (originalSenderContact == null) {
_loadContactIfNeeded(originalSenderId);
forwardedFrom = 'Участник $originalSenderId';
forwardedFromAvatarUrl = null;
} else {
forwardedFrom = originalSenderContact.name;
forwardedFromAvatarUrl =
originalSenderContact.photoBaseUrl;
}
}
}
}
}
String? senderName;
if (widget.isGroupChat && !isMe) {
@@ -1500,6 +1526,8 @@ class _ChatScreenState extends State<ChatScreen> {
isGroupChat: widget.isGroupChat,
isChannel: widget.isChannel,
senderName: senderName,
forwardedFrom: forwardedFrom,
forwardedFromAvatarUrl: forwardedFromAvatarUrl,
contactDetailsCache: _contactDetailsCache,
onReplyTap: _scrollToMessage,
useAutoReplyColor: context
@@ -1659,7 +1687,6 @@ class _ChatScreenState extends State<ChatScreen> {
leading: widget.isDesktopMode
? null // В десктопном режиме нет кнопки "Назад"
: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
@@ -1908,7 +1935,6 @@ class _ChatScreenState extends State<ChatScreen> {
),
)
else
_ContactPresenceSubtitle(
chatId: widget.chatId,
userId: widget.contact.id,
@@ -1998,7 +2024,6 @@ class _ChatScreenState extends State<ChatScreen> {
],
);
case ChatWallpaperType.video:
if (Platform.isWindows) {
return Container(
color: Theme.of(context).colorScheme.surface,
@@ -2195,16 +2220,13 @@ class _ChatScreenState extends State<ChatScreen> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Focus(
focusNode:
_textFocusNode, // 2. focusNode теперь здесь
onKeyEvent: (node, event) {
if (event is KeyDownEvent) {
if (event.logicalKey ==
LogicalKeyboardKey.enter) {
final bool isShiftPressed =
HardwareKeyboard.instance.logicalKeysPressed
.contains(
@@ -2216,7 +2238,6 @@ class _ChatScreenState extends State<ChatScreen> {
);
if (!isShiftPressed) {
_sendMessage();
return KeyEventResult.handled;
}
@@ -3292,7 +3313,6 @@ class GroupProfileDraggableDialog extends StatelessWidget {
),
child: Column(
children: [
Container(
margin: const EdgeInsets.only(top: 8),
width: 40,
@@ -3303,7 +3323,6 @@ class GroupProfileDraggableDialog extends StatelessWidget {
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Hero(
@@ -3325,7 +3344,6 @@ class GroupProfileDraggableDialog extends StatelessWidget {
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
@@ -3343,8 +3361,6 @@ class GroupProfileDraggableDialog extends StatelessWidget {
IconButton(
icon: Icon(Icons.settings, color: colors.primary),
onPressed: () async {
final myId = 0; // This should be passed or retrieved
Navigator.of(context).pop();
@@ -3367,13 +3383,11 @@ class GroupProfileDraggableDialog extends StatelessWidget {
const SizedBox(height: 20),
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
if (contact.description != null &&
contact.description!.isNotEmpty)
Text(
@@ -3545,7 +3559,6 @@ class ContactProfileDialog extends StatelessWidget {
},
)
else
const SizedBox(height: 16),
if (!isChannel)
@@ -3900,7 +3913,6 @@ class _RemoveMemberDialogState extends State<_RemoveMemberDialog> {
}
}
class _PromoteAdminDialog extends StatelessWidget {
final List<Map<String, dynamic>> members;
final Function(int) onPromoteToAdmin;
@@ -3964,7 +3976,6 @@ class _ControlMessageChip extends StatelessWidget {
});
String _formatControlMessage() {
final controlAttach = message.attaches.firstWhere(
(a) => a['_type'] == 'CONTROL',
);
@@ -3974,7 +3985,6 @@ class _ControlMessageChip extends StatelessWidget {
final isMe = message.senderId == myId;
final senderDisplayName = isMe ? 'Вы' : senderName;
String _formatUserList(List<int> userIds) {
if (userIds.isEmpty) {
return '';
@@ -4120,7 +4130,6 @@ class _ControlMessageChip extends StatelessWidget {
return '$senderName присоединился(ась) к группе';
default:
final eventTypeStr = eventType?.toString() ?? 'неизвестное';
return 'Событие: $eventTypeStr';
}
@@ -4153,15 +4162,12 @@ class _ControlMessageChip extends StatelessWidget {
}
void openUserProfileById(BuildContext context, int userId) {
final contact = ApiService.instance.getCachedContact(userId);
if (contact != null) {
final isGroup = contact.id < 0; // Groups have negative IDs
if (isGroup) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -4169,7 +4175,6 @@ void openUserProfileById(BuildContext context, int userId) {
builder: (context) => GroupProfileDraggableDialog(contact: contact),
);
} else {
Navigator.of(context).push(
PageRouteBuilder(
opaque: false,
@@ -4185,7 +4190,6 @@ void openUserProfileById(BuildContext context, int userId) {
);
}
} else {
showDialog(
context: context,
builder: (context) => AlertDialog(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,412 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:gwid/models/chat.dart';
import 'package:gwid/models/message.dart';
import 'package:gwid/models/contact.dart';
import 'package:gwid/models/profile.dart';
import 'package:gwid/api_service.dart';
import 'package:gwid/widgets/chat_message_bubble.dart';
import 'package:gwid/chat_screen.dart';
class MessagePreviewDialog {
static String _formatTimestamp(int timestamp) {
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
final now = DateTime.now();
if (now.day == dt.day && now.month == dt.month && now.year == dt.year) {
return DateFormat('HH:mm', 'ru').format(dt);
} else {
final yesterday = now.subtract(const Duration(days: 1));
if (dt.day == yesterday.day &&
dt.month == yesterday.month &&
dt.year == yesterday.year) {
return 'Вчера';
} else {
return DateFormat('d MMM', 'ru').format(dt);
}
}
}
static bool _isSavedMessages(Chat chat) {
return chat.id == 0;
}
static bool _isGroupChat(Chat chat) {
return chat.type == 'CHAT' || chat.participantIds.length > 2;
}
static bool _isSameDay(DateTime date1, DateTime date2) {
return date1.year == date2.year &&
date1.month == date2.month &&
date1.day == date2.day;
}
static bool _isMessageGrouped(
Message currentMessage,
Message? previousMessage,
) {
if (previousMessage == null) return false;
final currentTime = DateTime.fromMillisecondsSinceEpoch(
currentMessage.time,
);
final previousTime = DateTime.fromMillisecondsSinceEpoch(
previousMessage.time,
);
final timeDifference = currentTime.difference(previousTime).inMinutes;
return currentMessage.senderId == previousMessage.senderId &&
timeDifference <= 5;
}
static String _formatDateSeparator(DateTime date) {
final now = DateTime.now();
if (_isSameDay(date, now)) {
return 'Сегодня';
} else {
final yesterday = now.subtract(const Duration(days: 1));
if (_isSameDay(date, yesterday)) {
return 'Вчера';
} else {
return DateFormat('d MMM yyyy', 'ru').format(date);
}
}
}
static String _getChatTitle(Chat chat, Map<int, Contact> contacts) {
final bool isSavedMessages = _isSavedMessages(chat);
final bool isGroupChat = _isGroupChat(chat);
final bool isChannel = chat.type == 'CHANNEL';
if (isSavedMessages) {
return "Избранное";
} else if (isChannel) {
return chat.title ?? "Канал";
} else if (isGroupChat) {
return chat.title?.isNotEmpty == true ? chat.title! : "Группа";
} else {
final myId = chat.ownerId;
final otherParticipantId = chat.participantIds.firstWhere(
(id) => id != myId,
orElse: () => myId,
);
final contact = contacts[otherParticipantId];
return contact?.name ?? "Неизвестный чат";
}
}
static Future<void> show(
BuildContext context,
Chat chat,
Map<int, Contact> contacts,
Profile? myProfile,
VoidCallback? onClose,
Widget Function(BuildContext)? menuBuilder,
) async {
final colors = Theme.of(context).colorScheme;
List<Message> messages = [];
bool isLoading = true;
try {
messages = await ApiService.instance.getMessageHistory(
chat.id,
force: false,
);
if (messages.length > 10) {
messages = messages.sublist(messages.length - 10);
}
} catch (e) {
print('Ошибка загрузки сообщений для предпросмотра: $e');
} finally {
isLoading = false;
}
final Set<int> senderIds = messages.map((m) => m.senderId).toSet();
senderIds.remove(0);
final Set<int> forwardedSenderIds = {};
for (final message in messages) {
if (message.isForwarded && message.link != null) {
final link = message.link;
if (link is Map<String, dynamic>) {
final chatName = link['chatName'] as String?;
if (chatName == null) {
final forwardedMessage = link['message'] as Map<String, dynamic>?;
final originalSenderId = forwardedMessage?['sender'] as int?;
if (originalSenderId != null) {
forwardedSenderIds.add(originalSenderId);
}
}
}
}
}
final allIdsToFetch = {
...senderIds,
...forwardedSenderIds,
}.where((id) => !contacts.containsKey(id)).toList();
if (allIdsToFetch.isNotEmpty) {
try {
final newContacts = await ApiService.instance.fetchContactsByIds(
allIdsToFetch,
);
for (final contact in newContacts) {
contacts[contact.id] = contact;
}
} catch (e) {
print('Ошибка загрузки контактов для предпросмотра: $e');
}
}
final chatTitle = _getChatTitle(chat, contacts);
final bool isGroupChat = _isGroupChat(chat);
final bool isChannel = chat.type == 'CHANNEL';
final myId = myProfile?.id ?? chat.ownerId;
if (!context.mounted) return;
List<ChatItem> chatItems = [];
for (int i = 0; i < messages.length; i++) {
final currentMessage = messages[i];
final previousMessage = (i > 0) ? messages[i - 1] : null;
final currentDate = DateTime.fromMillisecondsSinceEpoch(
currentMessage.time,
).toLocal();
final previousDate = previousMessage != null
? DateTime.fromMillisecondsSinceEpoch(previousMessage.time).toLocal()
: null;
if (previousMessage == null || !_isSameDay(currentDate, previousDate!)) {
chatItems.add(DateSeparatorItem(currentDate));
}
final isGrouped = _isMessageGrouped(currentMessage, previousMessage);
final isFirstInGroup = previousMessage == null || !isGrouped;
final isLastInGroup =
i == messages.length - 1 ||
!_isMessageGrouped(messages[i + 1], currentMessage);
chatItems.add(
MessageItem(
currentMessage,
isFirstInGroup: isFirstInGroup,
isLastInGroup: isLastInGroup,
isGrouped: isGrouped,
),
);
}
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return DraggableScrollableSheet(
initialChildSize: 0.75,
minChildSize: 0.5,
maxChildSize: 0.9,
builder: (context, scrollController) {
return Container(
decoration: BoxDecoration(
color: colors.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.only(top: 8, bottom: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: colors.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: colors.outline.withOpacity(0.2),
width: 1,
),
),
),
child: Row(
children: [
Expanded(
child: Text(
chatTitle,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: colors.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close),
color: colors.onSurfaceVariant,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
Navigator.of(context).pop();
onClose?.call();
},
),
],
),
),
Expanded(
child: isLoading
? const Center(child: CircularProgressIndicator())
: messages.isEmpty
? Center(
child: Text(
'Нет сообщений',
style: TextStyle(
color: colors.onSurfaceVariant,
fontSize: 14,
),
),
)
: ListView.builder(
controller: scrollController,
reverse: true,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
itemCount: chatItems.length,
itemBuilder: (context, index) {
final mappedIndex = chatItems.length - 1 - index;
final item = chatItems[mappedIndex];
if (item is DateSeparatorItem) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: colors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_formatDateSeparator(item.date),
style: TextStyle(
fontSize: 12,
color: colors.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
),
);
}
if (item is MessageItem) {
final message = item.message;
final isMe = message.senderId == myId;
final senderContact =
contacts[message.senderId];
final senderName = isMe
? 'Вы'
: (senderContact?.name ?? 'Неизвестный');
String? forwardedFrom;
String? forwardedFromAvatarUrl;
if (message.isForwarded) {
final link = message.link;
if (link is Map<String, dynamic>) {
final chatName =
link['chatName'] as String?;
final chatIconUrl =
link['chatIconUrl'] as String?;
if (chatName != null) {
forwardedFrom = chatName;
forwardedFromAvatarUrl = chatIconUrl;
} else {
final forwardedMessage =
link['message']
as Map<String, dynamic>?;
final originalSenderId =
forwardedMessage?['sender'] as int?;
if (originalSenderId != null) {
final originalSenderContact =
contacts[originalSenderId];
forwardedFrom =
originalSenderContact?.name ??
'Участник $originalSenderId';
forwardedFromAvatarUrl =
originalSenderContact?.photoBaseUrl;
}
}
}
}
return ChatMessageBubble(
message: message,
isMe: isMe,
readStatus: null,
deferImageLoading: true,
myUserId: myId,
chatId: chat.id,
onReply: null,
onEdit: null,
canEditMessage: null,
onDeleteForMe: null,
onDeleteForAll: null,
onReaction: null,
onRemoveReaction: null,
isGroupChat: isGroupChat,
isChannel: isChannel,
senderName: senderName,
forwardedFrom: forwardedFrom,
forwardedFromAvatarUrl:
forwardedFromAvatarUrl,
contactDetailsCache: contacts,
onReplyTap: null,
useAutoReplyColor: false,
customReplyColor: null,
isFirstInGroup: item.isFirstInGroup,
isLastInGroup: item.isLastInGroup,
isGrouped: item.isGrouped,
avatarVerticalOffset: -8.0,
);
}
return const SizedBox.shrink();
},
),
),
if (menuBuilder != null) ...[
Divider(height: 1, color: colors.outline.withOpacity(0.2)),
menuBuilder(context),
],
],
),
);
},
);
},
);
}
}

View File

@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "Komet")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.gwid.com.gwid")
set(APPLICATION_ID "com.gwid.app.gwid")

View File

@@ -801,10 +801,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.16.0"
mime:
dependency: transitive
description:
@@ -1310,10 +1310,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.6"
timezone:
dependency: "direct main"
description: