пятки мне целуйте нахуй

This commit is contained in:
Jganenokk
2026-04-03 20:32:22 +07:00
parent 91069f53ea
commit 91d2afbb57
8 changed files with 446 additions and 115 deletions
+2 -2
View File
@@ -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);
} }
} }
+163
View File
@@ -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
>(), // Сохраняем весь пакет для гибкости (аттачи и т.д.)
);
}
}
+58 -53
View File
@@ -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-заголовка).
+61 -1
View File
@@ -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],
);
}
} }
+7 -9
View File
@@ -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,
),
), ),
); );
} }
+148 -49
View File
@@ -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
), ),
), ),
), ),
+2
View File
@@ -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();