Files
JustAMassenger/client/lib/screens/chat_screen.dart
T
SashegDev 096c4d0a2d Initial commit: JustAMessenger v0.1.0
Серверная часть (Go):
- WebSocket сервер с бинарным протоколом
- XChaCha20-Poly1305 шифрование
- zstd сжатие с дедупликацией (64KB чанки)
- SQLite хранилище (WAL режим)
- Управление гильдиями, каналами, ролями
- Федерация между серверами (ed25519)
- REST API + WebSocket endpoints

Клиентская часть (Flutter):
- Material Design 3 тёмная тема (Discord-like)
- WebSocket соединение с сервером
- Экраны: сплэш, логин, домашний, гильдии, чат
- Модели: пользователи, гильдии, каналы, сообщения, роли
- Сервисы: соединение, API, криптография, тема
- Виджеты: иконки гильдий, сообщения, ввод чата
- Web сборка (PWA)

Документация:
- AGENTS.md — контекст для ИИ ассистентов
- docs/protocol.md — спецификация протокола
2026-06-06 22:39:14 +00:00

238 lines
7.0 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/connection_service.dart';
import '../services/api_service.dart';
import '../services/theme_service.dart';
import '../models/message.dart';
import '../models/channel.dart';
import '../models/guild.dart';
import '../widgets/message_bubble.dart';
import '../widgets/chat_input.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final List<Message> _messages = [];
StreamSubscription? _sub;
bool _loading = true;
bool _atBottom = true;
late Channel _channel;
late Guild _guild;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final args = ModalRoute.of(context)?.settings.arguments as Map?;
if (args != null) {
_channel = args['channel'] as Channel;
_guild = args['guild'] as Guild;
}
}
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) => _loadMessages());
_sub = context.read<ConnectionService>().messageStream.listen(_onMessage);
}
@override
void dispose() {
_sub?.cancel();
_messageController.dispose();
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.hasClients) {
final atBottom = _scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 50;
if (atBottom != _atBottom) {
setState(() => _atBottom = atBottom);
}
}
}
Future<void> _loadMessages() async {
try {
final conn = context.read<ConnectionService>();
final api = ApiService();
api.updateFromWsUrl(conn.serverUrl);
final messages = await api.getMessages(_channel.id);
if (mounted) {
setState(() {
_messages.addAll(messages.reversed);
_loading = false;
});
_scrollToBottom();
}
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
void _onMessage(Map<String, dynamic> data) {
final msgChannelId = data['channel_id'] as String?;
if (msgChannelId != _channel.id) return;
final msg = Message.fromJson(data);
setState(() => _messages.add(msg));
if (_atBottom) _scrollToBottom();
}
void _sendMessage(String text) {
if (text.trim().isEmpty) return;
final conn = context.read<ConnectionService>();
final content = Uint8List.fromList(utf8.encode(text));
conn.sendMessage(channelId: _channel.id, content: content);
_messageController.clear();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ThemeService.backgroundPrimary,
body: Column(
children: [
_buildHeader(),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator(strokeWidth: 2))
: _messages.isEmpty
? _buildEmptyState()
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: _messages.length,
itemBuilder: (context, index) {
final msg = _messages[index];
final showAuthor = index == 0 ||
_messages[index - 1].authorId != msg.authorId;
return MessageBubble(
message: msg,
showAuthor: showAuthor,
onReply: (m) => _messageController.text = '@${m.authorUsername ?? "unknown"} ',
);
},
),
),
ChatInput(
controller: _messageController,
onSendMessage: _sendMessage,
channelId: _channel.id,
),
],
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: const BoxDecoration(
color: ThemeService.surfaceSecondary,
border: Border(bottom: BorderSide(color: Color(0xFF3F4147))),
),
child: Row(
children: [
Icon(
_channel.isText ? Icons.tag : Icons.volume_up,
size: 20,
color: ThemeService.textMuted,
),
const SizedBox(width: 8),
Text(
_channel.name,
style: const TextStyle(
color: ThemeService.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
if (_channel.isVoice)
IconButton(
icon: const Icon(Icons.call, size: 20),
color: ThemeService.success,
onPressed: () {},
tooltip: 'Start call',
),
IconButton(
icon: const Icon(Icons.search, size: 20),
color: ThemeService.textSecondary,
onPressed: () {},
tooltip: 'Search',
),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_channel.isText ? Icons.tag : Icons.volume_up,
size: 48,
color: ThemeService.textMuted.withValues(alpha: 0.5),
),
const SizedBox(height: 12),
Text(
'#${_channel.name}',
style: const TextStyle(
color: ThemeService.textPrimary,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
if (_channel.topic != null && _channel.topic!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
_channel.topic!,
style: const TextStyle(
color: ThemeService.textSecondary,
fontSize: 13,
),
),
],
const SizedBox(height: 16),
Text(
'No messages yet. Start the conversation!',
style: TextStyle(
color: ThemeService.textMuted.withValues(alpha: 0.8),
fontSize: 14,
),
),
],
),
);
}
}