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(
|
Future<List<Map<String, dynamic>>> getChatHistory(
|
||||||
int contactId,
|
int contactId,
|
||||||
int myId,
|
int myId, {
|
||||||
) async {
|
int? limit,
|
||||||
|
int? offset,
|
||||||
|
}) async {
|
||||||
final query = select(messages)
|
final query = select(messages)
|
||||||
..where(
|
..where(
|
||||||
(tbl) =>
|
(tbl) =>
|
||||||
(tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId)) |
|
(tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId)) |
|
||||||
(tbl.senderId.equals(myId) & tbl.receiverId.equals(contactId)),
|
(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();
|
final rows = await query.get();
|
||||||
return rows.map((row) => row.toJson()).toList();
|
return rows.map((row) => row.toJson()).toList();
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@ class ApiService extends ChangeNotifier {
|
||||||
Future<Contact?> getUserByUsername(String username) async {
|
Future<Contact?> getUserByUsername(String username) async {
|
||||||
try {
|
try {
|
||||||
// Подставляй свой эндпоинт, например: /users/by-username/
|
// Подставляй свой эндпоинт, например: /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) {
|
if (response.statusCode == 200 && response.data != null) {
|
||||||
// Парсим полученные данные в модель контакта.
|
// Парсим полученные данные в модель контакта.
|
||||||
|
|
@ -411,11 +413,15 @@ class ApiService extends ChangeNotifier {
|
||||||
|
|
||||||
Future<List<dynamic>> getChatHistory(
|
Future<List<dynamic>> getChatHistory(
|
||||||
int contactId, {
|
int contactId, {
|
||||||
|
int? anchorId,
|
||||||
|
int limitBefore = 20,
|
||||||
|
int limitAfter = 20,
|
||||||
bool forceRefresh = false,
|
bool forceRefresh = false,
|
||||||
}) async {
|
}) async {
|
||||||
final token = await getAccessToken();
|
final token = await getAccessToken();
|
||||||
|
|
||||||
final Map<String, String> requestHeaders = {
|
final Map<String, String> requestHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
'Authorization': 'Bearer $token',
|
'Authorization': 'Bearer $token',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -423,15 +429,14 @@ class ApiService extends ChangeNotifier {
|
||||||
requestHeaders['Cache-Control'] = 'no-cache';
|
requestHeaders['Cache-Control'] = 'no-cache';
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await http.get(
|
// Собираем URL с новыми параметрами двунаправленной пагинации
|
||||||
Uri.parse(
|
String urlStr =
|
||||||
'${AppConstants.baseUrl}/messages/history/${contactId.toString()}',
|
'${AppConstants.baseUrl}/messages/history/${contactId.toString()}?limit_before=$limitBefore&limit_after=$limitAfter';
|
||||||
),
|
if (anchorId != null) {
|
||||||
headers: {
|
urlStr += '&anchor_id=$anchorId';
|
||||||
'Content-Type': 'application/json',
|
}
|
||||||
"Authorization": "Bearer $token",
|
|
||||||
},
|
final response = await http.get(Uri.parse(urlStr), headers: requestHeaders);
|
||||||
);
|
|
||||||
return jsonDecode(utf8.decode(response.bodyBytes)) as List<dynamic>;
|
return jsonDecode(utf8.decode(response.bodyBytes)) as List<dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ class ChatScreen extends StatefulWidget {
|
||||||
final void Function(Contact contact)? onOpenProfile;
|
final void Function(Contact contact)? onOpenProfile;
|
||||||
final VoidCallback? onBack;
|
final VoidCallback? onBack;
|
||||||
final bool showBackButton;
|
final bool showBackButton;
|
||||||
|
final Function(int contactId)? onMessageRead;
|
||||||
|
|
||||||
const ChatScreen({
|
const ChatScreen({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -51,6 +52,7 @@ class ChatScreen extends StatefulWidget {
|
||||||
this.onOpenProfile,
|
this.onOpenProfile,
|
||||||
this.onBack,
|
this.onBack,
|
||||||
this.showBackButton = true,
|
this.showBackButton = true,
|
||||||
|
this.onMessageRead,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -88,7 +90,7 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
String? _pendingFileName;
|
String? _pendingFileName;
|
||||||
File? _pendingFile;
|
File? _pendingFile;
|
||||||
Uint8List? _previewBytes;
|
Uint8List? _previewBytes;
|
||||||
double _inputBarHeight = 0;
|
double _inputBarHeight = 64;
|
||||||
|
|
||||||
SecretKey? _chatSharedSecret;
|
SecretKey? _chatSharedSecret;
|
||||||
final Map<String, ValueNotifier<double?>> _messageProgressNotifiers = {};
|
final Map<String, ValueNotifier<double?>> _messageProgressNotifiers = {};
|
||||||
|
|
@ -115,6 +117,15 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
List<CameraDescription>? _cameras;
|
List<CameraDescription>? _cameras;
|
||||||
bool _isCameraInitialized = false;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -603,8 +614,8 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
lName = '';
|
lName = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
final String localFullName = '${_currentContact.name} ${_currentContact.surname}'
|
final String localFullName =
|
||||||
.trim();
|
'${_currentContact.name} ${_currentContact.surname}'.trim();
|
||||||
|
|
||||||
final contactInitials = localFullName.isNotEmpty
|
final contactInitials = localFullName.isNotEmpty
|
||||||
? localFullName
|
? localFullName
|
||||||
|
|
@ -726,8 +737,6 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
reverse: true,
|
reverse: true,
|
||||||
cacheExtent:
|
|
||||||
0, // Сохраняем: строго запрещает предзагрузку элементов вне экрана
|
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
|
|
@ -743,133 +752,199 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
top: 8,
|
top: 8,
|
||||||
),
|
),
|
||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate(
|
||||||
final msg = messages[messages.length - 1 - index];
|
(context, index) {
|
||||||
final keyId = msg.id ?? msg.tempId ?? index;
|
// 1. НИЖНИЙ ЛОАДЕР (если есть куда листать вниз к новым сообщениям)
|
||||||
final itemKey = _messageKeys.putIfAbsent(
|
if (_hasMoreNewer && index == 0) {
|
||||||
keyId,
|
return Container(
|
||||||
() => GlobalKey(),
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||||
);
|
alignment: Alignment.center,
|
||||||
final isMedia =
|
child: const SizedBox(
|
||||||
msg.messageType == MessageType.image ||
|
width: 20,
|
||||||
msg.messageType == MessageType.video ||
|
height: 20,
|
||||||
msg.messageType == MessageType.file;
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
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(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12,
|
vertical: 10,
|
||||||
vertical: 4,
|
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
alignment: Alignment.center,
|
||||||
color: Theme.of(context)
|
child: Container(
|
||||||
.colorScheme
|
padding: const EdgeInsets.symmetric(
|
||||||
.surfaceContainerHighest
|
horizontal: 12,
|
||||||
.withOpacity(0.75),
|
vertical: 4,
|
||||||
borderRadius: BorderRadius.circular(12),
|
),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: Text(
|
color: Theme.of(context)
|
||||||
_formatDividerDate(msg.createdAt),
|
.colorScheme
|
||||||
style: TextStyle(
|
.surfaceContainerHighest
|
||||||
fontSize: 12,
|
.withOpacity(0.75),
|
||||||
fontWeight: FontWeight.w600,
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Theme.of(
|
),
|
||||||
context,
|
child: Text(
|
||||||
).colorScheme.onSurfaceVariant,
|
_formatDividerDate(msg.createdAt),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Dismissible(
|
||||||
Dismissible(
|
direction: DismissDirection.endToStart,
|
||||||
direction: DismissDirection.endToStart,
|
key: ValueKey<String>('dismiss_$keyId'),
|
||||||
key: ValueKey<String>('dismiss_$keyId'),
|
confirmDismiss:
|
||||||
confirmDismiss:
|
(DismissDirection direction) async {
|
||||||
(DismissDirection direction) async {
|
String text = msg.text;
|
||||||
String text = msg.text;
|
if (msg.text.isEmpty &&
|
||||||
if (msg.text.isEmpty &&
|
msg.messageType ==
|
||||||
msg.messageType == MessageType.image) {
|
MessageType.image) {
|
||||||
text = "[Фото]";
|
text = "[Фото]";
|
||||||
}
|
}
|
||||||
setState(
|
setState(
|
||||||
() => _replyTo = msg.copyWith(text: text),
|
() =>
|
||||||
);
|
_replyTo = msg.copyWith(text: text),
|
||||||
return false;
|
);
|
||||||
},
|
return false;
|
||||||
child: RepaintBoundary(
|
},
|
||||||
child: MessageBubble(
|
child: RepaintBoundary(
|
||||||
key: ValueKey(
|
child: MessageBubble(
|
||||||
'${msg.id ?? msg.tempId}_${msg.localFile?.path ?? 'none'}',
|
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,
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Обычный текст возвращаем без детектора видимости
|
final bool isUnreadIncoming =
|
||||||
return itemChild;
|
!msg.isMe && msg.status != MessageStatus.read;
|
||||||
}, childCount: messages.length),
|
|
||||||
|
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,
|
bottom: 16,
|
||||||
child: _MeasureSize(
|
child: _MeasureSize(
|
||||||
onChange: (size) {
|
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(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
|
|
@ -2196,7 +2254,7 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
'-i',
|
'-i',
|
||||||
tempInputPath,
|
tempInputPath,
|
||||||
'-vf',
|
'-vf',
|
||||||
'crop=min(iw\\,ih):min(iw\\,ih),scale=512:512',
|
'crop=min(iw\,ih):min(iw\,ih),scale=512:512',
|
||||||
'-vcodec',
|
'-vcodec',
|
||||||
'libx264',
|
'libx264',
|
||||||
'-crf',
|
'-crf',
|
||||||
|
|
@ -2806,16 +2864,6 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
|
|
||||||
if (!mounted) return;
|
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(
|
final replyToText = await _decryptReplyText(
|
||||||
data['reply_to_text']?.toString(),
|
data['reply_to_text']?.toString(),
|
||||||
sharedSecret,
|
sharedSecret,
|
||||||
|
|
@ -2904,24 +2952,23 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
await prefs.remove(_notificationLaunchKey);
|
await prefs.remove(_notificationLaunchKey);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
print('[DEBUG] Начало загрузки истории');
|
print('[DEBUG] Начало загрузки анкерной истории');
|
||||||
final myPrivKey = await _cryptoService.getPrivateKey();
|
final myPrivKey = await _cryptoService.getPrivateKey();
|
||||||
final sharedSecret = await _cryptoService.deriveSharedSecret(
|
final sharedSecret = await _cryptoService.deriveSharedSecret(
|
||||||
myPrivKey!,
|
myPrivKey!,
|
||||||
widget.contact.publicKey!,
|
widget.contact.publicKey!,
|
||||||
);
|
);
|
||||||
print('[DEBUG] Ключи получены');
|
|
||||||
_chatSharedSecret = sharedSecret;
|
_chatSharedSecret = sharedSecret;
|
||||||
|
|
||||||
// 1. БЫСТРАЯ ЗАГРУЗКА ИЗ ЛОКАЛЬНОЙ БД (Оно читает ШИФРТЕКСТ и дешифрует для UI)
|
// 1. БЫСТРАЯ ЗАГРУЗКА ИЗ ЛОКАЛЬНОЙ БД
|
||||||
final cached = await _localDbService.getChatHistory(
|
final cached = await _localDbService.getChatHistory(
|
||||||
widget.contact.id,
|
widget.contact.id,
|
||||||
myId,
|
myId,
|
||||||
);
|
);
|
||||||
print('[DEBUG] Локальная история загружена: ${cached.length} сообщений');
|
|
||||||
|
|
||||||
Map<int, MessageModel> localMessagesMap = {};
|
Map<int, MessageModel> localMessagesMap = {};
|
||||||
|
|
||||||
|
int? anchorId;
|
||||||
|
|
||||||
for (var msg in cached) {
|
for (var msg in cached) {
|
||||||
final parsed = await _parseAndDecryptMessage(
|
final parsed = await _parseAndDecryptMessage(
|
||||||
msg,
|
msg,
|
||||||
|
|
@ -2931,6 +2978,14 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
);
|
);
|
||||||
if (parsed != null && parsed.id != null) {
|
if (parsed != null && parsed.id != null) {
|
||||||
localMessagesMap[parsed.id!] = parsed;
|
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
|
// 2. ФОНОВАЯ ЗАГРУЗКА АКТУАЛЬНОЙ ИСТОРИИ ИЗ API ВОКРУГ АНКЕРА
|
||||||
final history = await apiService.getChatHistory(widget.contact.id);
|
final history = await apiService.getChatHistory(
|
||||||
print('[DEBUG] Загружена история из API: ${history.length}');
|
widget.contact.id,
|
||||||
|
anchorId: anchorId,
|
||||||
|
limitBefore: _limitBefore,
|
||||||
|
limitAfter: _limitAfter,
|
||||||
|
);
|
||||||
|
print(
|
||||||
|
'[DEBUG] Загружена история из API вокрут анкера: ${history.length}',
|
||||||
|
);
|
||||||
|
|
||||||
final alreadyReadIncomingMessageIds = <int>{};
|
final alreadyReadIncomingMessageIds = <int>{};
|
||||||
List<MessageModel> loadedMessages =
|
List<MessageModel> loadedMessages = [];
|
||||||
[]; // Список для отображения в UI (дешифрованный)
|
List<MessageModel> encryptedMessagesForStorage = [];
|
||||||
List<MessageModel> encryptedMessagesForStorage =
|
|
||||||
[]; // Список для кэширования в БД (ЗАШИФРОВАННЫЙ)
|
|
||||||
|
|
||||||
for (var msg in history) {
|
for (var msg in history) {
|
||||||
final msgId = int.tryParse(msg['id']?.toString() ?? '');
|
final msgId = int.tryParse(msg['id']?.toString() ?? '');
|
||||||
|
|
@ -2961,11 +3021,9 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
alreadyReadIncomingMessageIds.add(msgId);
|
alreadyReadIncomingMessageIds.add(msgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Извлекаем оригинальный сырой шифртекст из ответа сервера
|
|
||||||
final String rawCiphertext = msg['content'].toString();
|
final String rawCiphertext = msg['content'].toString();
|
||||||
final String? rawEncryptedReplyText = msg['reply_to_text']?.toString();
|
final String? rawEncryptedReplyText = msg['reply_to_text']?.toString();
|
||||||
|
|
||||||
// Парсим и расшифровываем сообщение для UI (с защитой от повторной дешифровки)
|
|
||||||
final parsed = await _parseAndDecryptMessage(
|
final parsed = await _parseAndDecryptMessage(
|
||||||
msg,
|
msg,
|
||||||
sharedSecret,
|
sharedSecret,
|
||||||
|
|
@ -2974,21 +3032,17 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
);
|
);
|
||||||
if (parsed != null) {
|
if (parsed != null) {
|
||||||
loadedMessages.add(parsed);
|
loadedMessages.add(parsed);
|
||||||
|
|
||||||
// ФИКС КВОТЫ И БЕЗОПАСНОСТИ: Создаем клон модели специально для БД,
|
|
||||||
// подменяя открытый текст на исходный шифртекст сервера
|
|
||||||
encryptedMessagesForStorage.add(
|
encryptedMessagesForStorage.add(
|
||||||
MessageModel(
|
MessageModel(
|
||||||
id: parsed.id,
|
id: parsed.id,
|
||||||
text: rawCiphertext, // ТЕПЕРЬ ТУТ ХРАНИТСЯ ШИФРТЕКСТ
|
text: rawCiphertext,
|
||||||
isMe: parsed.isMe,
|
isMe: parsed.isMe,
|
||||||
senderId: parsed.senderId,
|
senderId: parsed.senderId,
|
||||||
receiverId: parsed.receiverId,
|
receiverId: parsed.receiverId,
|
||||||
createdAt: parsed.createdAt,
|
createdAt: parsed.createdAt,
|
||||||
status: parsed.status,
|
status: parsed.status,
|
||||||
replyToId: parsed.replyToId,
|
replyToId: parsed.replyToId,
|
||||||
replyToText:
|
replyToText: rawEncryptedReplyText,
|
||||||
rawEncryptedReplyText, // ТЕПЕРЬ ТУТ ТОЖЕ ШИФРТЕКСТ ОТВЕТА
|
|
||||||
editedAt: parsed.editedAt,
|
editedAt: parsed.editedAt,
|
||||||
messageType: parsed.messageType,
|
messageType: parsed.messageType,
|
||||||
fileId: parsed.fileId,
|
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 {
|
try {
|
||||||
print(
|
if (encryptedMessagesForStorage.isNotEmpty) {
|
||||||
'[DEBUG] Начинаем сохранение истории в локальную БД в зашифрованном виде',
|
await _localDbService.saveMessages(encryptedMessagesForStorage);
|
||||||
);
|
}
|
||||||
// Передаем список с шифртекстом. Метод saveMessages запишет в базу именно его
|
|
||||||
await _localDbService.saveMessages(encryptedMessagesForStorage);
|
|
||||||
print('[DEBUG] Сообщения успешно защищены и сохранены в локальную бд');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("[ERROR] Ошибка сохранения истории в локальную базу: $e");
|
print("[ERROR] Ошибка сохранения истории в локальную базу: $e");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. ФИНАЛЬНОЕ ОБНОВЛЕНИЕ ИНТЕРФЕЙСА (Интерфейс видит чистый текст, база — шифртекст)
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
messages = loadedMessages;
|
// 1. Сортируем пришедшие от сервера данные в хронологическом порядке
|
||||||
loadedMessages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
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;
|
_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 (anchorId != null) {
|
||||||
if (m.isMe || m.id == null) continue;
|
_suppressPagination =
|
||||||
if (alreadyReadIncomingMessageIds.contains(m.id)) continue;
|
true; // Жестко держим блок во время рендеринга и прыжка
|
||||||
if (_sentReadReceipts.contains(m.id)) continue;
|
|
||||||
|
|
||||||
Provider.of<SocketService>(
|
/*WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context,
|
Future.delayed(const Duration(milliseconds: 1500), () async {
|
||||||
listen: false,
|
await _scrollToMessage(anchorId);
|
||||||
).sendReadReceipt(m.id!);
|
if (mounted) {
|
||||||
_sentReadReceipts.add(m.id!);
|
setState(() {
|
||||||
|
_suppressPagination =
|
||||||
|
false; // Прыжок завершен, теперь скроллить безопасно!
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});*/
|
||||||
|
} else {
|
||||||
|
// Если анкера нет, пагинация разрешена сразу
|
||||||
|
_suppressPagination = false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Ошибка загрузки истории: $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(
|
Future<MessageModel?> _parseAndDecryptMessage(
|
||||||
Map<String, dynamic> msg,
|
Map<String, dynamic> msg,
|
||||||
SecretKey sharedSecret,
|
SecretKey sharedSecret,
|
||||||
|
|
@ -3110,12 +3378,10 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
? MessageStatus.sent
|
? MessageStatus.sent
|
||||||
: MessageStatus.delivered;
|
: MessageStatus.delivered;
|
||||||
|
|
||||||
if (senderId == myId) {
|
if (readAt != null) {
|
||||||
if (readAt != null) {
|
status = MessageStatus.read;
|
||||||
status = MessageStatus.read;
|
} else if (deliveredAt != null) {
|
||||||
} else if (deliveredAt != null) {
|
status = MessageStatus.delivered;
|
||||||
status = MessageStatus.delivered;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final rawFileId = msg['file_id']?.toString() ?? msg['fileId']?.toString();
|
final rawFileId = msg['file_id']?.toString() ?? msg['fileId']?.toString();
|
||||||
|
|
@ -3538,6 +3804,24 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
|
|
||||||
Future<void> _updateScrollButtonVisibility() async {
|
Future<void> _updateScrollButtonVisibility() async {
|
||||||
if (!mounted) return;
|
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 =
|
final shouldShow =
|
||||||
_scrollController.hasClients && _scrollController.offset > 100;
|
_scrollController.hasClients && _scrollController.offset > 100;
|
||||||
if (shouldShow != _showScrollToEnd) {
|
if (shouldShow != _showScrollToEnd) {
|
||||||
|
|
|
||||||
|
|
@ -337,7 +337,8 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
||||||
final localName = _localFullNames[contact.id];
|
final localName = _localFullNames[contact.id];
|
||||||
final displayName = (localName != null && localName.isNotEmpty)
|
final displayName = (localName != null && localName.isNotEmpty)
|
||||||
? localName
|
? 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
|
final contactInitials = displayName.isNotEmpty
|
||||||
? displayName
|
? displayName
|
||||||
|
|
@ -565,6 +566,23 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
||||||
onOpenProfile: _openProfile,
|
onOpenProfile: _openProfile,
|
||||||
onBack: _clearSelectedContact,
|
onBack: _clearSelectedContact,
|
||||||
showBackButton: false,
|
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,
|
contact_id: int,
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
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) |
|
chat_filter = (
|
||||||
(models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id)
|
((models.Message.sender_id == current_user.id) & (models.Message.receiver_id == contact_id)) |
|
||||||
).order_by(models.Message.timestamp.desc()).limit(limit).all()
|
((models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id))
|
||||||
print(
|
|
||||||
f"DEBUG get_chat_history: user={current_user.id}, contact={contact_id}, count={len(messages)}, ids={[m.id for m in messages]}",
|
|
||||||
)
|
)
|
||||||
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")
|
@messagesRouter.get("/last")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue