пятки мне целуйте нахуй
This commit is contained in:
@@ -199,8 +199,8 @@ class Api {
|
|||||||
logger.i('Сессия: ${state.name}');
|
logger.i('Сессия: ${state.name}');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDataReceived(Uint8List data) {
|
Future<void> _onDataReceived(Uint8List data) async {
|
||||||
for (final packet in _receiver.feed(data)) {
|
await for (final packet in _receiver.feed(data)) {
|
||||||
_dispatcher.dispatch(packet);
|
_dispatcher.dispatch(packet);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import '../api.dart';
|
||||||
|
import '../../core/protocol/opcode_map.dart';
|
||||||
|
import '../../core/storage/app_database.dart';
|
||||||
|
|
||||||
|
class CachedMessage {
|
||||||
|
final String id;
|
||||||
|
final int accountId;
|
||||||
|
final int chatId;
|
||||||
|
final int senderId;
|
||||||
|
final String? text;
|
||||||
|
final int time;
|
||||||
|
final String? status;
|
||||||
|
final Map<String, dynamic>? payload;
|
||||||
|
|
||||||
|
const CachedMessage({
|
||||||
|
required this.id,
|
||||||
|
required this.accountId,
|
||||||
|
required this.chatId,
|
||||||
|
required this.senderId,
|
||||||
|
this.text,
|
||||||
|
required this.time,
|
||||||
|
this.status,
|
||||||
|
this.payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CachedMessage.fromDbRow(Map<String, dynamic> row) {
|
||||||
|
Map<String, dynamic>? payload;
|
||||||
|
final payloadRaw = row['payload'];
|
||||||
|
if (payloadRaw is String && payloadRaw.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
payload = jsonDecode(payloadRaw) as Map<String, dynamic>;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CachedMessage(
|
||||||
|
id: row['id'] as String,
|
||||||
|
accountId: row['account_id'] as int,
|
||||||
|
chatId: row['chat_id'] as int,
|
||||||
|
senderId: row['sender_id'] as int,
|
||||||
|
text: row['text'] as String?,
|
||||||
|
time: row['time'] as int,
|
||||||
|
status: row['status'] as String?,
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toDbRow() => {
|
||||||
|
'id': id,
|
||||||
|
'account_id': accountId,
|
||||||
|
'chat_id': chatId,
|
||||||
|
'sender_id': senderId,
|
||||||
|
'text': text,
|
||||||
|
'time': time,
|
||||||
|
'status': status,
|
||||||
|
'payload': payload != null ? jsonEncode(payload) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessagesModule {
|
||||||
|
final Api _api;
|
||||||
|
|
||||||
|
MessagesModule(this._api);
|
||||||
|
|
||||||
|
/// Загружает историю сообщений для указанного чата.
|
||||||
|
///
|
||||||
|
/// [fromTime] — опционально, время от которого грузить (миллисекунды).
|
||||||
|
/// Если не указано, грузит самые свежие.
|
||||||
|
/// [count] — количество сообщений.
|
||||||
|
Future<List<CachedMessage>> fetchHistory(
|
||||||
|
int accountId,
|
||||||
|
int chatId, {
|
||||||
|
int? fromTime,
|
||||||
|
int count = 50,
|
||||||
|
}) async {
|
||||||
|
final payload = {
|
||||||
|
'chatId': chatId,
|
||||||
|
'from':
|
||||||
|
fromTime ??
|
||||||
|
(DateTime.now().millisecondsSinceEpoch +
|
||||||
|
86400000), // +1 день для запаса
|
||||||
|
'forward': 0,
|
||||||
|
'backward': count,
|
||||||
|
'getMessages': true,
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await _api.sendRequest(Opcode.chatHistory, payload);
|
||||||
|
|
||||||
|
if (!response.isOk) return [];
|
||||||
|
|
||||||
|
final data = response.payload;
|
||||||
|
if (data is! Map) return [];
|
||||||
|
|
||||||
|
final messagesData = data['messages'];
|
||||||
|
if (messagesData is! List) return [];
|
||||||
|
|
||||||
|
final List<CachedMessage> results = [];
|
||||||
|
final List<Map<String, dynamic>> rows = [];
|
||||||
|
|
||||||
|
// Обрабатываем сообщения "кусочками", чтобы не фризить UI при маппинге большого количества данных
|
||||||
|
for (var i = 0; i < messagesData.length; i++) {
|
||||||
|
final m = messagesData[i];
|
||||||
|
if (m is! Map) continue;
|
||||||
|
|
||||||
|
final msg = _parseMessage(m.cast<dynamic, dynamic>(), accountId, chatId);
|
||||||
|
if (msg != null) {
|
||||||
|
results.add(msg);
|
||||||
|
rows.add(msg.toDbRow());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Каждые 20 сообщений даем UI-потоку "дохнуть" и отрисовать кадр
|
||||||
|
if (i > 0 && i % 20 == 0) {
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.isNotEmpty) {
|
||||||
|
AppDatabase.saveMessages(rows).ignore();
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загружает сообщения из локальной базы данных.
|
||||||
|
Future<List<CachedMessage>> getLocalHistory(
|
||||||
|
int accountId,
|
||||||
|
int chatId, {
|
||||||
|
int limit = 50,
|
||||||
|
int offset = 0,
|
||||||
|
}) async {
|
||||||
|
final rows = await AppDatabase.loadMessages(
|
||||||
|
accountId,
|
||||||
|
chatId,
|
||||||
|
limit: limit,
|
||||||
|
offset: offset,
|
||||||
|
);
|
||||||
|
return rows.map(CachedMessage.fromDbRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
CachedMessage? _parseMessage(
|
||||||
|
Map<dynamic, dynamic> m,
|
||||||
|
int accountId,
|
||||||
|
int chatId,
|
||||||
|
) {
|
||||||
|
final id = m['id']?.toString();
|
||||||
|
if (id == null) return null;
|
||||||
|
|
||||||
|
return CachedMessage(
|
||||||
|
id: id,
|
||||||
|
accountId: accountId,
|
||||||
|
chatId: chatId,
|
||||||
|
senderId: (m['sender'] as int?) ?? 0,
|
||||||
|
text: m['text'] as String?,
|
||||||
|
time: (m['time'] as int?) ?? 0,
|
||||||
|
status: m['status'] as String?,
|
||||||
|
payload: m
|
||||||
|
.cast<
|
||||||
|
String,
|
||||||
|
dynamic
|
||||||
|
>(), // Сохраняем весь пакет для гибкости (аттачи и т.д.)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'dart:isolate';
|
||||||
import 'package:dart_lz4/dart_lz4.dart';
|
import 'package:dart_lz4/dart_lz4.dart';
|
||||||
import 'package:msgpack_dart/msgpack_dart.dart' as msgpack;
|
import 'package:msgpack_dart/msgpack_dart.dart' as msgpack;
|
||||||
|
|
||||||
@@ -69,71 +70,75 @@ Uint8List packPacket(int opcode, Map<dynamic, dynamic> payload, {int seq = 0}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Распаковка пакета от сервера
|
/// Распаковка пакета от сервера
|
||||||
Packet unpackPacket(Uint8List packet) {
|
Future<Packet> unpackPacket(Uint8List packet) async {
|
||||||
// Для удобства расшифровки пакета переводим в ByteData
|
return Isolate.run(() {
|
||||||
ByteData packetData = ByteData.view(
|
// Для удобства расшифровки пакета переводим в ByteData
|
||||||
packet.buffer,
|
ByteData packetData = ByteData.view(
|
||||||
packet.offsetInBytes,
|
packet.buffer,
|
||||||
packet.lengthInBytes,
|
packet.offsetInBytes,
|
||||||
);
|
packet.lengthInBytes,
|
||||||
|
);
|
||||||
|
|
||||||
// Объяснение каждой переменной смотри в классе Packet
|
// API версия и cmd представляют из себя 8 битные числа
|
||||||
|
final apiVer = packetData.getUint8(0) & 0xFF;
|
||||||
|
final cmd = packetData.getUint8(1) & 0xFF;
|
||||||
|
|
||||||
// API версия и cmd представляют из себя 8 битные числа
|
// Sequence и OPCode представляют из себя 16 битные числа
|
||||||
final apiVer = packetData.getUint8(0) & 0xFF;
|
final seq = packetData.getUint16(2) & 0xFFFF;
|
||||||
final cmd = packetData.getUint8(1) & 0xFF;
|
final opcode = packetData.getUint16(4) & 0xFFFF;
|
||||||
|
|
||||||
// Sequence и OPCode представляют из себя 16 битные числа
|
// После базовых переменных идет длина пакета, является 32 битным числом
|
||||||
final seq = packetData.getUint16(2) & 0xFFFF;
|
final packedLen = packetData.getUint32(6);
|
||||||
final opcode = packetData.getUint16(4) & 0xFFFF;
|
|
||||||
|
|
||||||
// После базовых переменных идет длина пакета, является 32 битным числом
|
// Compression flag показывает, сжат ли payload
|
||||||
final packedLen = packetData.getUint32(6);
|
final compFlag = packedLen >> 24;
|
||||||
|
|
||||||
// Compression flag показывает, сжат ли payload
|
// Длина payload'а
|
||||||
final compFlag = packedLen >> 24;
|
final payloadLength = packedLen & 0xFFFFFF;
|
||||||
|
|
||||||
// Длина payload'а
|
|
||||||
final payloadLength = packedLen & 0xFFFFFF;
|
|
||||||
|
|
||||||
// Байты payload'а, могут быть сжаты LZ4
|
// Байты payload'а, могут быть сжаты LZ4
|
||||||
var payloadBytes = packet.buffer.asUint8List(10, payloadLength);
|
var payloadBytes = packet.buffer.asUint8List(10, payloadLength);
|
||||||
|
|
||||||
dynamic payload;
|
dynamic payload;
|
||||||
|
|
||||||
if (payloadBytes.isNotEmpty) {
|
if (payloadBytes.isNotEmpty) {
|
||||||
if (compFlag != 0) {
|
if (compFlag != 0) {
|
||||||
try {
|
|
||||||
payloadBytes = lz4Decompress(
|
|
||||||
payloadBytes,
|
|
||||||
decompressedSize: _maxDecompressedSize,
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (_) {
|
|
||||||
try {
|
try {
|
||||||
payloadBytes = _lz4BlockDecompress(payloadBytes, _maxDecompressedSize);
|
payloadBytes = lz4Decompress(
|
||||||
} catch (e) {
|
payloadBytes,
|
||||||
logger.e("LZ4 decompression error: $e", error: e);
|
decompressedSize: _maxDecompressedSize,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
payloadBytes = _lz4BlockDecompress(
|
||||||
|
payloadBytes,
|
||||||
|
_maxDecompressedSize,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// В изоляте нельзя использовать логгер, который пишет в терминал через зависимости Flutter,
|
||||||
|
// но простой print или throw сработает
|
||||||
|
print("LZ4 decompression error: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
payload = msgpack.deserialize(payloadBytes);
|
||||||
|
} catch (e) {
|
||||||
|
if (payloadBytes.isNotEmpty) {
|
||||||
|
print("MsgPack deserialization error: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return Packet(
|
||||||
payload = msgpack.deserialize(payloadBytes);
|
api: apiVer,
|
||||||
} catch (e) {
|
cmd: cmd,
|
||||||
if (payloadBytes.isNotEmpty) {
|
seq: seq,
|
||||||
logger.e("MsgPack deserialization error: $e", error: e);
|
opcode: opcode,
|
||||||
}
|
payload: payload,
|
||||||
}
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
return Packet(
|
|
||||||
api: apiVer,
|
|
||||||
cmd: cmd,
|
|
||||||
seq: seq,
|
|
||||||
opcode: opcode,
|
|
||||||
payload: payload,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LZ4 block декомпрессия (без frame-заголовка).
|
/// LZ4 block декомпрессия (без frame-заголовка).
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ class AppDatabase {
|
|||||||
final dbPath = await getDatabasesPath();
|
final dbPath = await getDatabasesPath();
|
||||||
return openDatabase(
|
return openDatabase(
|
||||||
join(dbPath, 'komet.db'),
|
join(dbPath, 'komet.db'),
|
||||||
version: 5,
|
version: 6,
|
||||||
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||||
onCreate: (db, _) => _createTables(db),
|
onCreate: (db, _) => _createTables(db),
|
||||||
onUpgrade: (db, oldVersion, newVersion) async {
|
onUpgrade: (db, oldVersion, newVersion) async {
|
||||||
@@ -141,6 +141,9 @@ class AppDatabase {
|
|||||||
await db.execute('DROP TABLE IF EXISTS chats_cache');
|
await db.execute('DROP TABLE IF EXISTS chats_cache');
|
||||||
await db.execute(_chatsCacheSchema);
|
await db.execute(_chatsCacheSchema);
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 6) {
|
||||||
|
await db.execute(_messagesSchema);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -164,6 +167,7 @@ class AppDatabase {
|
|||||||
await db.execute(_syncStateSchema);
|
await db.execute(_syncStateSchema);
|
||||||
await db.execute(_chatsCacheSchema);
|
await db.execute(_chatsCacheSchema);
|
||||||
await db.execute(_contactsSchema);
|
await db.execute(_contactsSchema);
|
||||||
|
await db.execute(_messagesSchema);
|
||||||
}
|
}
|
||||||
|
|
||||||
static const _contactsSchema = '''
|
static const _contactsSchema = '''
|
||||||
@@ -211,6 +215,21 @@ class AppDatabase {
|
|||||||
)
|
)
|
||||||
''';
|
''';
|
||||||
|
|
||||||
|
static const _messagesSchema = '''
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||||
|
chat_id INTEGER NOT NULL,
|
||||||
|
sender_id INTEGER NOT NULL,
|
||||||
|
text TEXT,
|
||||||
|
time INTEGER NOT NULL,
|
||||||
|
status TEXT,
|
||||||
|
payload TEXT,
|
||||||
|
PRIMARY KEY (id, account_id),
|
||||||
|
FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''';
|
||||||
|
|
||||||
static Future<void> saveProfile(ProfileData profile) async {
|
static Future<void> saveProfile(ProfileData profile) async {
|
||||||
final db = await _instance;
|
final db = await _instance;
|
||||||
await db.insert(
|
await db.insert(
|
||||||
@@ -360,4 +379,45 @@ class AppDatabase {
|
|||||||
whereArgs: [accountId],
|
whereArgs: [accountId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<void> saveMessages(List<Map<String, dynamic>> rows) async {
|
||||||
|
final db = await _instance;
|
||||||
|
await db.transaction((txn) async {
|
||||||
|
final batch = txn.batch();
|
||||||
|
for (final row in rows) {
|
||||||
|
batch.insert(
|
||||||
|
'messages',
|
||||||
|
row,
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await batch.commit(noResult: true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> loadMessages(
|
||||||
|
int accountId,
|
||||||
|
int chatId, {
|
||||||
|
int? limit,
|
||||||
|
int? offset,
|
||||||
|
}) async {
|
||||||
|
final db = await _instance;
|
||||||
|
return db.query(
|
||||||
|
'messages',
|
||||||
|
where: 'account_id = ? AND chat_id = ?',
|
||||||
|
whereArgs: [accountId, chatId],
|
||||||
|
orderBy: 'time DESC',
|
||||||
|
limit: limit,
|
||||||
|
offset: offset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> clearMessages(int accountId, int chatId) async {
|
||||||
|
final db = await _instance;
|
||||||
|
await db.delete(
|
||||||
|
'messages',
|
||||||
|
where: 'account_id = ? AND chat_id = ?',
|
||||||
|
whereArgs: [accountId, chatId],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,22 +10,22 @@ class PacketReceiver {
|
|||||||
|
|
||||||
static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта
|
static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта
|
||||||
|
|
||||||
/// Добавляет байты в буфер, возвращает все собранные пакеты.
|
/// Добавляет байты в буфер, возвращает поток собранных пакетов.
|
||||||
/// Неполные данные остаются в буфере до следующего вызова.
|
/// Неполные данные остаются в буфере до следующего вызова.
|
||||||
List<Packet> feed(Uint8List data) {
|
Stream<Packet> feed(Uint8List data) async* {
|
||||||
final newBuffer = Uint8List(_buffer.length + data.length);
|
final newBuffer = Uint8List(_buffer.length + data.length);
|
||||||
newBuffer.setAll(0, _buffer);
|
newBuffer.setAll(0, _buffer);
|
||||||
newBuffer.setAll(_buffer.length, data);
|
newBuffer.setAll(_buffer.length, data);
|
||||||
_buffer = newBuffer;
|
_buffer = newBuffer;
|
||||||
|
|
||||||
if (_buffer.length > _maxBufferSize) {
|
if (_buffer.length > _maxBufferSize) {
|
||||||
logger.e('PacketReceiver: переполнение буфера (${_buffer.length} B), сброс');
|
logger.e(
|
||||||
|
'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс',
|
||||||
|
);
|
||||||
reset();
|
reset();
|
||||||
return [];
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final packets = <Packet>[];
|
|
||||||
|
|
||||||
while (_buffer.length >= headerSize) {
|
while (_buffer.length >= headerSize) {
|
||||||
final bd = ByteData.view(
|
final bd = ByteData.view(
|
||||||
_buffer.buffer,
|
_buffer.buffer,
|
||||||
@@ -42,13 +42,11 @@ class PacketReceiver {
|
|||||||
_buffer = _buffer.sublist(totalLength);
|
_buffer = _buffer.sublist(totalLength);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
packets.add(unpackPacket(packetBytes));
|
yield await unpackPacket(packetBytes);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.e('PacketReceiver: ошибка распаковки: $e');
|
logger.e('PacketReceiver: ошибка распаковки: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return packets;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
|
|||||||
@@ -735,7 +735,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => ChatScreen(name: name, imageUrl: imageUrl),
|
builder: (context) => ChatScreen(
|
||||||
|
chatId: int.parse(id),
|
||||||
|
name: name,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,63 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'dart:ui';
|
import '../../../main.dart';
|
||||||
|
import '../../../core/storage/app_database.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
|
final int chatId;
|
||||||
final String name;
|
final String name;
|
||||||
final String imageUrl;
|
final String imageUrl;
|
||||||
|
|
||||||
const ChatScreen({super.key, required this.name, required this.imageUrl});
|
const ChatScreen({
|
||||||
|
super.key,
|
||||||
|
required this.chatId,
|
||||||
|
required this.name,
|
||||||
|
required this.imageUrl,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatScreen> createState() => _ChatScreenState();
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChatScreenState extends State<ChatScreen> {
|
class _ChatScreenState extends State<ChatScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
final TextEditingController _messageController = TextEditingController();
|
final TextEditingController _messageController = TextEditingController();
|
||||||
bool _hasText = false;
|
bool _hasText = false;
|
||||||
|
bool _isLoading = true;
|
||||||
|
late AnimationController _shimmerController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_messageController.addListener(_onTextChanged);
|
_messageController.addListener(_onTextChanged);
|
||||||
|
_shimmerController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1500),
|
||||||
|
)..repeat();
|
||||||
|
_loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHistory() async {
|
||||||
|
try {
|
||||||
|
final activeProfile = await AppDatabase.loadActiveProfile();
|
||||||
|
final myId = activeProfile?.id ?? 0;
|
||||||
|
await messagesModule.fetchHistory(myId, widget.chatId);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Background history fetch failed: $e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_messageController.removeListener(_onTextChanged);
|
_messageController.removeListener(_onTextChanged);
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
|
_shimmerController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,9 +74,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
extendBodyBehindAppBar: true,
|
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: const Color(0xFF1B1B1B).withOpacity(0.8),
|
backgroundColor: const Color(0xFF1B1B1B),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
@@ -77,21 +108,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Text(
|
||||||
children: [
|
widget.name,
|
||||||
Text(
|
style: const TextStyle(
|
||||||
widget.name,
|
color: Colors.white,
|
||||||
style: const TextStyle(
|
fontSize: 16,
|
||||||
color: Colors.white,
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: 16,
|
fontFamily: 'Outfit',
|
||||||
fontWeight: FontWeight.w600,
|
),
|
||||||
fontFamily: 'Outfit',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const Text(
|
const Text(
|
||||||
'Connecting...',
|
'last seen recently',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -118,38 +145,112 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Expanded(
|
||||||
child: widget.imageUrl.isNotEmpty
|
child: _isLoading
|
||||||
? Image.network(
|
? _buildShimmerLoading()
|
||||||
widget.imageUrl,
|
: const SizedBox.shrink(),
|
||||||
fit: BoxFit.cover,
|
|
||||||
opacity: const AlwaysStoppedAnimation(0.4),
|
|
||||||
)
|
|
||||||
: Image.network(
|
|
||||||
'https://images.unsplash.com/photo-1579546929518-9e396f3cc809',
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
opacity: const AlwaysStoppedAnimation(0.4),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const Positioned.fill(
|
_buildInputArea(context),
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Colors.transparent, Colors.black87],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Column(children: [const Spacer(), _buildInputArea(context)]),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildShimmerLoading() {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _shimmerController,
|
||||||
|
builder: (context, child) {
|
||||||
|
final opacity = 0.3 + (0.4 * _shimmerController.value);
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: 8,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final hasImage = index % 3 == 0;
|
||||||
|
final hasReactions = index % 2 == 0;
|
||||||
|
final width1 = 60.0 + (index * 15 % 50);
|
||||||
|
final width2 = 120.0 + (index * 25 % 80);
|
||||||
|
|
||||||
|
return Opacity(
|
||||||
|
opacity: opacity,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16.0),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF2B2B2B),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: width1,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF2B2B2B),
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Container(
|
||||||
|
width: width2,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF2B2B2B),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasImage) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 120,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF2B2B2B),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (hasReactions) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: List.generate(
|
||||||
|
3,
|
||||||
|
(i) => Container(
|
||||||
|
width: 32,
|
||||||
|
height: 16,
|
||||||
|
margin: const EdgeInsets.only(right: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF2B2B2B),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildInputArea(BuildContext context) {
|
Widget _buildInputArea(BuildContext context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -160,7 +261,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
curve: Curves.easeOutCubic,
|
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
minHeight: 54,
|
minHeight: 54,
|
||||||
maxHeight: 180,
|
maxHeight: 180,
|
||||||
@@ -173,10 +273,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
width: 0.5,
|
width: 0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
horizontal: 14,
|
|
||||||
vertical: 0,
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@@ -205,7 +302,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
vertical: 14,
|
||||||
|
), // Выравниваем по Y
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'backend/api.dart';
|
import 'backend/api.dart';
|
||||||
import 'backend/modules/account.dart';
|
import 'backend/modules/account.dart';
|
||||||
|
import 'backend/modules/messages.dart';
|
||||||
import 'core/storage/app_database.dart';
|
import 'core/storage/app_database.dart';
|
||||||
import 'core/storage/token_storage.dart';
|
import 'core/storage/token_storage.dart';
|
||||||
import 'frontend/screens/auth/login_screen.dart';
|
import 'frontend/screens/auth/login_screen.dart';
|
||||||
@@ -10,6 +11,7 @@ import 'frontend/screens/chats/chat_list_screen.dart';
|
|||||||
|
|
||||||
final api = Api();
|
final api = Api();
|
||||||
final accountModule = AccountModule(api);
|
final accountModule = AccountModule(api);
|
||||||
|
final messagesModule = MessagesModule(api);
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|||||||
Reference in New Issue
Block a user