05-06-26+01-10

This commit is contained in:
Artur 2026-06-05 01:10:33 +05:00
parent e9b025a34d
commit 37b5e265cd
6 changed files with 1082 additions and 365 deletions

View File

@ -182,15 +182,26 @@ class LocalDbService extends _$LocalDbService {
Future<List<Map<String, dynamic>>> getChatHistory(
int contactId,
int myId,
) async {
int myId, {
int? limit,
int? offset,
}) async {
final query = select(messages)
..where(
(tbl) =>
(tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId)) |
(tbl.senderId.equals(myId) & tbl.receiverId.equals(contactId)),
)
..orderBy([(tbl) => OrderingTerm(expression: tbl.timestamp)]);
..orderBy([
(tbl) => OrderingTerm(
expression: tbl.timestamp,
mode: OrderingMode.desc,
),
]);
if (limit != null) {
query.limit(limit, offset: offset);
}
final rows = await query.get();
return rows.map((row) => row.toJson()).toList();

View File

@ -20,7 +20,9 @@ class ApiService extends ChangeNotifier {
Future<Contact?> getUserByUsername(String username) async {
try {
// Подставляй свой эндпоинт, например: /users/by-username/
final response = await Dio().get('${AppConstants.baseUrl}//users/by-username/$username');
final response = await Dio().get(
'${AppConstants.baseUrl}//users/by-username/$username',
);
if (response.statusCode == 200 && response.data != null) {
// Парсим полученные данные в модель контакта.
@ -411,11 +413,15 @@ class ApiService extends ChangeNotifier {
Future<List<dynamic>> getChatHistory(
int contactId, {
int? anchorId,
int limitBefore = 20,
int limitAfter = 20,
bool forceRefresh = false,
}) async {
final token = await getAccessToken();
final Map<String, String> requestHeaders = {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
};
@ -423,15 +429,14 @@ class ApiService extends ChangeNotifier {
requestHeaders['Cache-Control'] = 'no-cache';
}
final response = await http.get(
Uri.parse(
'${AppConstants.baseUrl}/messages/history/${contactId.toString()}',
),
headers: {
'Content-Type': 'application/json',
"Authorization": "Bearer $token",
},
);
// Собираем URL с новыми параметрами двунаправленной пагинации
String urlStr =
'${AppConstants.baseUrl}/messages/history/${contactId.toString()}?limit_before=$limitBefore&limit_after=$limitAfter';
if (anchorId != null) {
urlStr += '&anchor_id=$anchorId';
}
final response = await http.get(Uri.parse(urlStr), headers: requestHeaders);
return jsonDecode(utf8.decode(response.bodyBytes)) as List<dynamic>;
}

View File

@ -44,6 +44,7 @@ class ChatScreen extends StatefulWidget {
final void Function(Contact contact)? onOpenProfile;
final VoidCallback? onBack;
final bool showBackButton;
final Function(int contactId)? onMessageRead;
const ChatScreen({
super.key,
@ -51,6 +52,7 @@ class ChatScreen extends StatefulWidget {
this.onOpenProfile,
this.onBack,
this.showBackButton = true,
this.onMessageRead,
});
@override
@ -88,7 +90,7 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
String? _pendingFileName;
File? _pendingFile;
Uint8List? _previewBytes;
double _inputBarHeight = 0;
double _inputBarHeight = 64;
SecretKey? _chatSharedSecret;
final Map<String, ValueNotifier<double?>> _messageProgressNotifiers = {};
@ -115,6 +117,15 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
List<CameraDescription>? _cameras;
bool _isCameraInitialized = false;
int _limitBefore = 20;
int _limitAfter = 20;
bool _isLoadingOlder = false;
bool _isLoadingNewer = false;
bool _hasMoreOlder = true;
bool _hasMoreNewer = false;
bool _suppressPagination = true;
bool _isFirstSizeMeasurement = true;
@override
void initState() {
super.initState();
@ -603,8 +614,8 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
lName = '';
}
final String localFullName = '${_currentContact.name} ${_currentContact.surname}'
.trim();
final String localFullName =
'${_currentContact.name} ${_currentContact.surname}'.trim();
final contactInitials = localFullName.isNotEmpty
? localFullName
@ -726,8 +737,6 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
child: CustomScrollView(
controller: _scrollController,
reverse: true,
cacheExtent:
0, // Сохраняем: строго запрещает предзагрузку элементов вне экрана
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverPadding(
@ -743,133 +752,199 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
top: 8,
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final msg = messages[messages.length - 1 - index];
final keyId = msg.id ?? msg.tempId ?? index;
final itemKey = _messageKeys.putIfAbsent(
keyId,
() => GlobalKey(),
);
final isMedia =
msg.messageType == MessageType.image ||
msg.messageType == MessageType.video ||
msg.messageType == MessageType.file;
final showDateDivider = _isNewDay(index);
// Формируем основное содержимое элемента сообщения
Widget itemChild = Column(
crossAxisAlignment: CrossAxisAlignment.start,
key: itemKey,
mainAxisSize: MainAxisSize.min,
children: [
if (showDateDivider)
Container(
padding: const EdgeInsets.symmetric(
vertical: 10,
delegate: SliverChildBuilderDelegate(
(context, index) {
// 1. НИЖНИЙ ЛОАДЕР (если есть куда листать вниз к новым сообщениям)
if (_hasMoreNewer && index == 0) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 20),
alignment: Alignment.center,
child: const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
alignment: Alignment.center,
child: Container(
),
);
}
// Сдвигаем рабочий индекс для массива сообщений, если внизу отрисован лоадер
final int messageIndexInSliver = _hasMoreNewer
? index - 1
: index;
// 2. ВЕРХНИЙ ЛОАДЕР (если есть куда листать вверх к старой истории)
if (_hasMoreOlder &&
messageIndexInSliver == messages.length) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 20),
alignment: Alignment.center,
child: const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
);
}
final msg =
messages[messages.length -
1 -
messageIndexInSliver];
final keyId = msg.id ?? msg.tempId ?? index;
final itemKey = _messageKeys.putIfAbsent(
keyId,
() => GlobalKey(),
);
final isMedia =
msg.messageType == MessageType.image ||
msg.messageType == MessageType.video ||
msg.messageType == MessageType.file;
final showDateDivider = _isNewDay(
messageIndexInSliver,
);
// Формируем основное содержимое элемента сообщения
Widget itemChild = Column(
crossAxisAlignment: CrossAxisAlignment.start,
key: itemKey,
mainAxisSize: MainAxisSize.min,
children: [
if (showDateDivider)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
vertical: 10,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withOpacity(0.75),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_formatDividerDate(msg.createdAt),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
alignment: Alignment.center,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withOpacity(0.75),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_formatDividerDate(msg.createdAt),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
),
),
),
Dismissible(
direction: DismissDirection.endToStart,
key: ValueKey<String>('dismiss_$keyId'),
confirmDismiss:
(DismissDirection direction) async {
String text = msg.text;
if (msg.text.isEmpty &&
msg.messageType == MessageType.image) {
text = "[Фото]";
}
setState(
() => _replyTo = msg.copyWith(text: text),
);
return false;
},
child: RepaintBoundary(
child: MessageBubble(
key: ValueKey(
'${msg.id ?? msg.tempId}_${msg.localFile?.path ?? 'none'}',
Dismissible(
direction: DismissDirection.endToStart,
key: ValueKey<String>('dismiss_$keyId'),
confirmDismiss:
(DismissDirection direction) async {
String text = msg.text;
if (msg.text.isEmpty &&
msg.messageType ==
MessageType.image) {
text = "[Фото]";
}
setState(
() =>
_replyTo = msg.copyWith(text: text),
);
return false;
},
child: RepaintBoundary(
child: MessageBubble(
key: ValueKey(
'${msg.id ?? msg.tempId}_${msg.localFile?.path ?? 'none'}',
),
message: msg,
onReplyTap: msg.replyToId != null
? () => _scrollToMessage(msg.replyToId)
: null,
onReplyToTap: () {
String text = msg.text;
if (msg.text.isEmpty &&
msg.messageType ==
MessageType.image) {
text = "[Фото]";
}
setState(
() =>
_replyTo = msg.copyWith(text: text),
);
},
onImageTap: () => _openFullScreenMedia(msg),
onEditTap: () => _editMessage(msg),
onDeleteTap: () => _deleteMessage(msg),
onDownloadRequested: (m) async {
await _ensureFileDecrypted(
m,
dontLoad: false,
);
},
onDownloadRequestedWithoutLoad: (m) async {
await _ensureFileDecrypted(
m,
dontLoad: true,
);
},
autoLoadMedia:
msg.messageType != MessageType.image
? true
: (msg.fileSize == null ||
msg.fileSize! <=
_autoMediaLoadLimitBytes),
downloadProgress:
_messageProgressNotifiers['${msg.fileId}'],
onDownloadStoped: (m) async {
await _stopFileLoading(msg);
},
),
message: msg,
onTap: () => _showMessageActions(msg),
onReplyTap: msg.replyToId != null
? () => _scrollToMessage(msg.replyToId)
: null,
onImageTap: () => _openFullScreenMedia(msg),
onDownloadRequested: (m) async {
await _ensureFileDecrypted(
m,
dontLoad: false,
);
},
onDownloadRequestedWithoutLoad: (m) async {
await _ensureFileDecrypted(
m,
dontLoad: true,
);
},
autoLoadMedia:
msg.messageType != MessageType.image
? true
: (msg.fileSize == null ||
msg.fileSize! <=
_autoMediaLoadLimitBytes),
downloadProgress:
_messageProgressNotifiers['${msg.fileId}'],
onDownloadStoped: (m) async {
await _stopFileLoading(msg);
},
),
),
),
],
);
// Если это медиафайл или документ, оборачиваем в VisibilityDetector
if (isMedia) {
return VisibilityDetector(
key: ValueKey('visible_${keyId}'),
onVisibilityChanged: (visibilityInfo) {
// Как только элемент показался в зоне видимости хотя бы на 10%
if (visibilityInfo.visibleFraction > 0.1) {
if (msg.fileSize == null || msg.fileSize == 0) {
print(
"Элемент стал видим. Фоновый запрос размера для: ${msg.fileId}",
);
_fetchFileSizeIfNeeded(msg);
}
}
},
child: itemChild,
],
);
}
// Обычный текст возвращаем без детектора видимости
return itemChild;
}, childCount: messages.length),
final bool isUnreadIncoming =
!msg.isMe && msg.status != MessageStatus.read;
if (isMedia || isUnreadIncoming) {
return VisibilityDetector(
key: ValueKey('visible_${keyId}'),
onVisibilityChanged: (visibilityInfo) {
if (visibilityInfo.visibleFraction > 0.35) {
if (isMedia &&
(msg.fileSize == null ||
msg.fileSize == 0)) {
_fetchFileSizeIfNeeded(msg);
}
if (isUnreadIncoming) {
_markAsRead(
msg,
); // Плавное чтение при попадании на экран
}
}
},
child: itemChild,
);
}
return itemChild;
},
// Если есть куда скроллить наверх, увеличиваем размер списка на 1 под лоадер
childCount:
messages.length +
(_hasMoreOlder ? 1 : 0) +
(_hasMoreNewer ? 1 : 0),
),
),
),
],
@ -977,23 +1052,6 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
bottom: 16,
child: _MeasureSize(
onChange: (size) {
if (_inputBarHeight != size.height) {
setState(() {
_inputBarHeight = size.height;
});
if (!_showScrollToEnd) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
}
},
child: SafeArea(
top: false,
@ -2196,7 +2254,7 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
'-i',
tempInputPath,
'-vf',
'crop=min(iw\\,ih):min(iw\\,ih),scale=512:512',
'crop=min(iw\,ih):min(iw\,ih),scale=512:512',
'-vcodec',
'libx264',
'-crf',
@ -2806,16 +2864,6 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
if (!mounted) return;
final serverMessageId = int.tryParse(data['id']?.toString() ?? '');
if (serverMessageId != null &&
!_sentReadReceipts.contains(serverMessageId)) {
Provider.of<SocketService>(
context,
listen: false,
).sendReadReceipt(serverMessageId);
_sentReadReceipts.add(serverMessageId);
}
final replyToText = await _decryptReplyText(
data['reply_to_text']?.toString(),
sharedSecret,
@ -2904,24 +2952,23 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
await prefs.remove(_notificationLaunchKey);
try {
print('[DEBUG] Начало загрузки истории');
print('[DEBUG] Начало загрузки анкерной истории');
final myPrivKey = await _cryptoService.getPrivateKey();
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!,
widget.contact.publicKey!,
);
print('[DEBUG] Ключи получены');
_chatSharedSecret = sharedSecret;
// 1. БЫСТРАЯ ЗАГРУЗКА ИЗ ЛОКАЛЬНОЙ БД (Оно читает ШИФРТЕКСТ и дешифрует для UI)
// 1. БЫСТРАЯ ЗАГРУЗКА ИЗ ЛОКАЛЬНОЙ БД
final cached = await _localDbService.getChatHistory(
widget.contact.id,
myId,
);
print('[DEBUG] Локальная история загружена: ${cached.length} сообщений');
Map<int, MessageModel> localMessagesMap = {};
int? anchorId;
for (var msg in cached) {
final parsed = await _parseAndDecryptMessage(
msg,
@ -2931,6 +2978,14 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
);
if (parsed != null && parsed.id != null) {
localMessagesMap[parsed.id!] = parsed;
// ТРИГГЕР АНКЕРА: Ищем первое непрочитанное сообщение от собеседника в локальном кэше
if (anchorId == null &&
!parsed.isMe &&
parsed.status != MessageStatus.read) {
anchorId = parsed.id;
print('[DEBUG] Найдено анкерное сообщение в кэше: ID $anchorId');
}
}
}
@ -2943,15 +2998,20 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
});
}
// 2. ФОНОВАЯ ЗАГРУЗКА АКТУАЛЬНОЙ ИСТОРИИ ИЗ API
final history = await apiService.getChatHistory(widget.contact.id);
print('[DEBUG] Загружена история из API: ${history.length}');
// 2. ФОНОВАЯ ЗАГРУЗКА АКТУАЛЬНОЙ ИСТОРИИ ИЗ API ВОКРУГ АНКЕРА
final history = await apiService.getChatHistory(
widget.contact.id,
anchorId: anchorId,
limitBefore: _limitBefore,
limitAfter: _limitAfter,
);
print(
'[DEBUG] Загружена история из API вокрут анкера: ${history.length}',
);
final alreadyReadIncomingMessageIds = <int>{};
List<MessageModel> loadedMessages =
[]; // Список для отображения в UI (дешифрованный)
List<MessageModel> encryptedMessagesForStorage =
[]; // Список для кэширования в БД (ЗАШИФРОВАННЫЙ)
List<MessageModel> loadedMessages = [];
List<MessageModel> encryptedMessagesForStorage = [];
for (var msg in history) {
final msgId = int.tryParse(msg['id']?.toString() ?? '');
@ -2961,11 +3021,9 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
alreadyReadIncomingMessageIds.add(msgId);
}
// Извлекаем оригинальный сырой шифртекст из ответа сервера
final String rawCiphertext = msg['content'].toString();
final String? rawEncryptedReplyText = msg['reply_to_text']?.toString();
// Парсим и расшифровываем сообщение для UI (с защитой от повторной дешифровки)
final parsed = await _parseAndDecryptMessage(
msg,
sharedSecret,
@ -2974,21 +3032,17 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
);
if (parsed != null) {
loadedMessages.add(parsed);
// ФИКС КВОТЫ И БЕЗОПАСНОСТИ: Создаем клон модели специально для БД,
// подменяя открытый текст на исходный шифртекст сервера
encryptedMessagesForStorage.add(
MessageModel(
id: parsed.id,
text: rawCiphertext, // ТЕПЕРЬ ТУТ ХРАНИТСЯ ШИФРТЕКСТ
text: rawCiphertext,
isMe: parsed.isMe,
senderId: parsed.senderId,
receiverId: parsed.receiverId,
createdAt: parsed.createdAt,
status: parsed.status,
replyToId: parsed.replyToId,
replyToText:
rawEncryptedReplyText, // ТЕПЕРЬ ТУТ ТОЖЕ ШИФРТЕКСТ ОТВЕТА
replyToText: rawEncryptedReplyText,
editedAt: parsed.editedAt,
messageType: parsed.messageType,
fileId: parsed.fileId,
@ -3001,39 +3055,70 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
}
}
loadedMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
// 3. СОХРАНЕНИЕ В ЛОКАЛЬНУЮ БД СТРОГО ЗАШИФРОВАННЫХ ДАННЫХ
try {
print(
'[DEBUG] Начинаем сохранение истории в локальную БД в зашифрованном виде',
);
// Передаем список с шифртекстом. Метод saveMessages запишет в базу именно его
await _localDbService.saveMessages(encryptedMessagesForStorage);
print('[DEBUG] Сообщения успешно защищены и сохранены в локальную бд');
if (encryptedMessagesForStorage.isNotEmpty) {
await _localDbService.saveMessages(encryptedMessagesForStorage);
}
} catch (e) {
print("[ERROR] Ошибка сохранения истории в локальную базу: $e");
}
// 4. ФИНАЛЬНОЕ ОБНОВЛЕНИЕ ИНТЕРФЕЙСА (Интерфейс видит чистый текст, база шифртекст)
if (!mounted) return;
setState(() {
messages = loadedMessages;
// 1. Сортируем пришедшие от сервера данные в хронологическом порядке
loadedMessages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
// --- УМНОЕ СЛИЯНИЕ ВМЕСТО ПЕРЕЗАПИСИ ---
// Собираем ID сообщений, которые только что прилетели из API
final apiIds = loadedMessages.map((m) => m.id).toSet();
// Оставляем в текущем списке (из кэша) только те сообщения, которых НЕТ в ответе API.
// Это сохранит длинную историю кэша (чтобы список не схлопнулся) + сбережет временные сообщения
final localPreserved = messages.where((m) => m.id == null || !apiIds.contains(m.id)).toList();
// Объединяем кэш и новые данные от сервера в единый массив
final combined = [...localPreserved, ...loadedMessages];
// Финально сортируем объединенный массив по дате
combined.sort((a, b) => a.createdAt.compareTo(b.createdAt));
messages = combined;
_isKeyLoading = false;
// --- ТОЧНЫЙ РАСЧЁТ ФЛАГОВ ПАГИНАЦИИ ---
if (anchorId != null) {
// Считаем, сколько сообщений реально пришло старше и новее нашего анкера в серверной пачке
final olderCount = loadedMessages.where((m) => m.id != null && m.id! < anchorId!).length;
final newerCount = loadedMessages.where((m) => m.id != null && m.id! > anchorId!).length;
// Крутилки загрузки появятся только если сервер вернул РОВНО лимит страниц (значит есть что качать дальше)
_hasMoreOlder = olderCount >= _limitBefore;
_hasMoreNewer = newerCount >= _limitAfter;
} else {
// Если анкера не было, мы в самом низу чата (у свежих сообщений)
_hasMoreNewer = false;
_hasMoreOlder = history.length >= _limitBefore;
}
});
// Отправка отчетов о прочтении...
for (final m in loadedMessages) {
if (m.isMe || m.id == null) continue;
if (alreadyReadIncomingMessageIds.contains(m.id)) continue;
if (_sentReadReceipts.contains(m.id)) continue;
// Скроллим к анкеру с управлением предохранителем
if (anchorId != null) {
_suppressPagination =
true; // Жестко держим блок во время рендеринга и прыжка
Provider.of<SocketService>(
context,
listen: false,
).sendReadReceipt(m.id!);
_sentReadReceipts.add(m.id!);
/*WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 1500), () async {
await _scrollToMessage(anchorId);
if (mounted) {
setState(() {
_suppressPagination =
false; // Прыжок завершен, теперь скроллить безопасно!
});
}
});
});*/
} else {
// Если анкера нет, пагинация разрешена сразу
_suppressPagination = false;
}
} catch (e) {
print("Ошибка загрузки истории: $e");
@ -3042,6 +3127,189 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
}
}
Future<void> _markAsRead(MessageModel msg) async {
// Проверяем, что сообщение не наше, еще не прочитано и имеет валидный серверный ID
if (msg.id == null || msg.isMe || msg.status == MessageStatus.read) return;
if (_sentReadReceipts.contains(msg.id)) return;
// Сразу блокируем повторную отправку
_sentReadReceipts.add(msg.id!);
print('[DEBUG] Плавное прочтение сообщения ID: ${msg.id}');
// 1. Отправляем сигнал на бэкенд через вебсокет
_socketService.sendReadReceipt(msg.id!);
// 2. Обновляем статус сообщения внутри текущего UI чата
_updateMessageInList(
msg.id!,
(m) => m.copyWith(status: MessageStatus.read),
);
// 3. Обновляем статус в локальной SQLite (Drift) базе данных
try {
await _localDbService.updateReadAt(msg.id!, DateTime.now());
} catch (e) {
print('Ошибка при сохранении статуса прочтения в БД: $e');
}
// 4. Вызываем твой callback, чтобы на десктопе/планшете счетчик в списке чатов уменьшался на лету
widget.onMessageRead?.call(_currentContact.id);
}
// 1. ПОДГРУЗКА СТАРЫХ СООБЩЕНИЙ (ВВЕРХ ХРОНОЛОГИИ)
Future<void> _loadOlderMessages() async {
if (_isLoadingOlder ||
!_hasMoreOlder ||
messages.isEmpty ||
_chatSharedSecret == null)
return;
setState(() => _isLoadingOlder = true);
try {
final int? anchorId = messages.first.id;
if (anchorId == null) return;
print('[DEBUG] Загрузка старой истории. Анкер: $anchorId');
final history = await apiService.getChatHistory(
_currentContact.id,
anchorId: anchorId,
limitBefore: _limitBefore,
limitAfter: 0,
);
if (history.isEmpty) {
setState(() {
_hasMoreOlder = false;
_isLoadingOlder = false;
});
return;
}
List<MessageModel> loadedOlder = [];
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
for (var msg in history) {
final parsed = await _parseAndDecryptMessage(
msg,
_chatSharedSecret!,
offset,
{},
);
if (parsed != null) loadedOlder.add(parsed);
}
if (!mounted) return;
setState(() {
loadedOlder.sort((a, b) => a.createdAt.compareTo(b.createdAt));
// --- ЗАЩИТА ОТ ДУБЛИКАТОВ ---
// Собираем ID всех сообщений, которые УЖЕ отрисованы на экране
final existingIds = messages.map((m) => m.id).toSet();
// Фильтруем новую пачку, убирая то, что уже есть (включая сообщение-анкер)
final uniqueOlder = loadedOlder
.where((m) => !existingIds.contains(m.id))
.toList();
// Вставляем в начало только уникальные сообщения
messages.insertAll(0, uniqueOlder);
_isLoadingOlder = false;
if (history.length < _limitBefore) _hasMoreOlder = false;
});
} catch (e) {
print("Ошибка загрузки старых сообщений: $e");
setState(() => _isLoadingOlder = false);
}
}
// 2. ПОДГРУЗКА НОВЫХ СООБЩЕНИЙ (ВНИЗ ХРОНОЛОГИИ К СВЕЖИМ)
Future<void> _loadNewerMessages() async {
if (_isLoadingNewer ||
!_hasMoreNewer ||
messages.isEmpty ||
_chatSharedSecret == null)
return;
setState(() => _isLoadingNewer = true);
try {
final int? anchorId = messages.last.id;
if (anchorId == null) return;
print('[DEBUG] Загрузка свежей истории. Анкер: $anchorId');
final history = await apiService.getChatHistory(
_currentContact.id,
anchorId: anchorId,
limitBefore: 0,
limitAfter: _limitAfter,
);
if (history.isEmpty) {
setState(() {
_hasMoreNewer = false;
_isLoadingNewer = false;
});
return;
}
List<MessageModel> loadedNewer = [];
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
for (var msg in history) {
final parsed = await _parseAndDecryptMessage(
msg,
_chatSharedSecret!,
offset,
{},
);
if (parsed != null) loadedNewer.add(parsed);
}
if (!mounted) return;
// --- СТАБИЛИЗАЦИЯ СКРОЛЛА: Фиксируем расстояние от верхнего (неизменного) края чата ---
double? distanceFromTop;
if (_scrollController.hasClients) {
distanceFromTop = _scrollController.position.maxScrollExtent - _scrollController.position.pixels;
}
setState(() {
loadedNewer.sort((a, b) => a.createdAt.compareTo(b.createdAt));
// Защита от дубликатов
final existingIds = messages.map((m) => m.id).toSet();
final uniqueNewer = loadedNewer
.where((m) => !existingIds.contains(m.id))
.toList();
// Добавляем новые порции в КОНЕЦ массива (вниз UI)
messages.addAll(uniqueNewer);
_isLoadingNewer = false;
if (history.length < _limitAfter) _hasMoreNewer = false;
});
// --- СТАБИЛИЗАЦИЯ СКРОЛЛА: Возвращаем скролл ровно на ту же инвариантную позицию ---
if (distanceFromTop != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
final double newMaxScroll = _scrollController.position.maxScrollExtent;
final double targetPixels = newMaxScroll - distanceFromTop!;
// Прыгаем мгновенно без анимации, пользователь вообще не заметит вставки элементов
_scrollController.jumpTo(targetPixels);
}
});
}
} catch (e) {
print("Ошибка загрузки новых сообщений: $e");
setState(() => _isLoadingNewer = false);
}
}
Future<MessageModel?> _parseAndDecryptMessage(
Map<String, dynamic> msg,
SecretKey sharedSecret,
@ -3110,12 +3378,10 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
? MessageStatus.sent
: MessageStatus.delivered;
if (senderId == myId) {
if (readAt != null) {
status = MessageStatus.read;
} else if (deliveredAt != null) {
status = MessageStatus.delivered;
}
if (readAt != null) {
status = MessageStatus.read;
} else if (deliveredAt != null) {
status = MessageStatus.delivered;
}
final rawFileId = msg['file_id']?.toString() ?? msg['fileId']?.toString();
@ -3538,6 +3804,24 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
Future<void> _updateScrollButtonVisibility() async {
if (!mounted) return;
if (_suppressPagination) return;
if (_scrollController.hasClients) {
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels;
// Прокрутка НАВЕРХ к старым сообщениям
if (maxScroll - currentScroll <= 200) {
_loadOlderMessages();
}
// Прокрутка ВНИЗ к новым сообщениям
if (currentScroll <= 200) {
_loadNewerMessages();
}
}
final shouldShow =
_scrollController.hasClients && _scrollController.offset > 100;
if (shouldShow != _showScrollToEnd) {

View File

@ -337,7 +337,8 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
final localName = _localFullNames[contact.id];
final displayName = (localName != null && localName.isNotEmpty)
? localName
: '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim();
: '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'
.trim();
final contactInitials = displayName.isNotEmpty
? displayName
@ -565,6 +566,23 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
onOpenProfile: _openProfile,
onBack: _clearSelectedContact,
showBackButton: false,
onMessageRead: (contactId) {
final contactProvider = context.read<ContactProvider>();
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == contactId,
orElse: () => _selectedContact!,
);
final currentUnread = contact.unreadCount;
if (currentUnread > 0) {
contactProvider.updateContact(
contactId,
unreadCount: currentUnread - 1,
);
}
},
);
}

File diff suppressed because it is too large Load Diff

View File

@ -25,16 +25,55 @@ async def get_chat_history(
contact_id: int,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
limit: int = 50
anchor_id: int = None, # ID сообщения, вокруг которого строим выборку
limit_before: int = 20, # Сколько сообщений загрузить ДО (старше анкера)
limit_after: int = 20 # Сколько сообщений загрузить ПОСЛЕ (новее анкера)
):
messages = db.query(models.Message).filter(
(models.Message.sender_id == current_user.id) & (models.Message.receiver_id == contact_id) |
(models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id)
).order_by(models.Message.timestamp.desc()).limit(limit).all()
print(
f"DEBUG get_chat_history: user={current_user.id}, contact={contact_id}, count={len(messages)}, ids={[m.id for m in messages]}",
# Базовый фильтр для получения сообщений конкретного диалога
chat_filter = (
((models.Message.sender_id == current_user.id) & (models.Message.receiver_id == contact_id)) |
((models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id))
)
return jsonable_encoder(messages)
# КЕЙС 1: Анкер не передан — отдаем самый "хвост" чата (последние свежие сообщения)
if anchor_id is None:
messages = db.query(models.Message).filter(chat_filter)\
.order_by(models.Message.id.desc())\
.limit(limit_before)\
.all()
print(f"DEBUG history (Tail): user={current_user.id}, contact={contact_id}, count={len(messages)}")
return jsonable_encoder(messages)
# КЕЙС 2: Передан anchor_id — собираем данные "вокруг" него
print(f"DEBUG history (Anchor): user={current_user.id}, contact={contact_id}, anchor={anchor_id}")
# 1. Тянем сообщения СТАРШЕ анкера (id < anchor_id)
# Сортируем DESC, чтобы взять ближайшие к анкеру сообщения
older_messages = db.query(models.Message).filter(chat_filter, models.Message.id < anchor_id)\
.order_by(models.Message.id.desc())\
.limit(limit_before)\
.all()
# 2. Тянем само якорное сообщение (чтобы оно гарантированно попало в выборку)
anchor_message = db.query(models.Message).filter(chat_filter, models.Message.id == anchor_id).first()
# 3. Тянем сообщения НОВЕЕ анкера (id > anchor_id)
# Сортируем ASC, чтобы взять идущие строго за анкером сообщения
newer_messages = db.query(models.Message).filter(chat_filter, models.Message.id > anchor_id)\
.order_by(models.Message.id.asc())\
.limit(limit_after)\
.all()
# Собираем все три куска в единый плоский список
combined_messages = older_messages + ([anchor_message] if anchor_message else []) + newer_messages
# ВАЖНО: Сортируем итоговый массив по убыванию (DESC),
# чтобы фронтенд получил структуру в привычном для него хронологическом порядке (от новых к старым)
combined_messages.sort(key=lambda msg: msg.id, reverse=True)
print(f"DEBUG history (Combined): total_count={len(combined_messages)}, older={len(older_messages)}, newer={len(newer_messages)}")
return jsonable_encoder(combined_messages)
@messagesRouter.get("/last")