05-06-26+01-10
This commit is contained in:
parent
e9b025a34d
commit
37b5e265cd
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue