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 — спецификация протокола
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../screens/splash_screen.dart';
|
||||
import '../screens/login_screen.dart';
|
||||
import '../screens/home_screen.dart';
|
||||
import '../screens/guild_screen.dart';
|
||||
import '../screens/chat_screen.dart';
|
||||
import '../screens/settings_screen.dart';
|
||||
import '../services/connection_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class JAMAppShell extends StatefulWidget {
|
||||
const JAMAppShell({super.key});
|
||||
|
||||
@override
|
||||
State<JAMAppShell> createState() => _JAMAppShellState();
|
||||
}
|
||||
|
||||
class _JAMAppShellState extends State<JAMAppShell> {
|
||||
final ConnectionService _connectionService = ConnectionService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_connectionService.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connectionService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: _connectionService),
|
||||
],
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'JustAMessenger',
|
||||
theme: ThemeData.dark().copyWith(
|
||||
colorScheme: ColorScheme.dark(
|
||||
primary: const Color(0xFF5865F2),
|
||||
secondary: const Color(0xFF3BA55D),
|
||||
surface: const Color(0xFF2F3136),
|
||||
error: const Color(0xFFED4245),
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFF36393F),
|
||||
),
|
||||
initialRoute: '/',
|
||||
routes: {
|
||||
'/': (context) => const SplashScreen(),
|
||||
'/login': (context) => const LoginScreen(),
|
||||
'/home': (context) => const HomeScreen(),
|
||||
'/guild': (context) => const GuildScreen(),
|
||||
'/chat': (context) => const ChatScreen(),
|
||||
'/settings': (context) => const SettingsScreen(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'app/app.dart';
|
||||
import 'services/theme_service.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const JAMApp());
|
||||
}
|
||||
|
||||
class JAMApp extends StatelessWidget {
|
||||
const JAMApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'JustAMessenger',
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [
|
||||
Locale('ru'),
|
||||
Locale('en'),
|
||||
],
|
||||
theme: ThemeService.darkTheme,
|
||||
darkTheme: ThemeService.darkTheme,
|
||||
themeMode: ThemeMode.dark,
|
||||
home: const JAMAppShell(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
class Channel {
|
||||
final String id;
|
||||
final String guildId;
|
||||
final String categoryId;
|
||||
final String name;
|
||||
final String type;
|
||||
final String? topic;
|
||||
final int position;
|
||||
final DateTime createdAt;
|
||||
|
||||
Channel({
|
||||
required this.id,
|
||||
required this.guildId,
|
||||
this.categoryId = '',
|
||||
required this.name,
|
||||
this.type = 'text',
|
||||
this.topic,
|
||||
this.position = 0,
|
||||
DateTime? createdAt,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
factory Channel.fromJson(Map<String, dynamic> json) {
|
||||
return Channel(
|
||||
id: json['id'] as String,
|
||||
guildId: json['guild_id'] as String,
|
||||
categoryId: json['category_id'] as String? ?? '',
|
||||
name: json['name'] as String,
|
||||
type: json['type'] as String? ?? 'text',
|
||||
topic: json['topic'] as String?,
|
||||
position: json['position'] as int? ?? 0,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
bool get isText => type == 'text';
|
||||
bool get isVoice => type == 'voice';
|
||||
bool get isStream => type == 'stream';
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'channel.dart';
|
||||
import 'role.dart';
|
||||
|
||||
class Guild {
|
||||
final String id;
|
||||
final String name;
|
||||
final String ownerId;
|
||||
final String? icon;
|
||||
final String? description;
|
||||
final DateTime createdAt;
|
||||
List<Channel> channels;
|
||||
List<Role> roles;
|
||||
List<Category> categories;
|
||||
|
||||
Guild({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.ownerId,
|
||||
this.icon,
|
||||
this.description,
|
||||
DateTime? createdAt,
|
||||
List<Channel>? channels,
|
||||
List<Role>? roles,
|
||||
List<Category>? categories,
|
||||
}) : createdAt = createdAt ?? DateTime.now(),
|
||||
channels = channels ?? [],
|
||||
roles = roles ?? [],
|
||||
categories = categories ?? [];
|
||||
|
||||
factory Guild.fromJson(Map<String, dynamic> json) {
|
||||
return Guild(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
ownerId: json['owner_id'] as String,
|
||||
icon: json['icon'] as String?,
|
||||
description: json['description'] as String?,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Category {
|
||||
final String id;
|
||||
final String name;
|
||||
final int position;
|
||||
|
||||
Category({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.position,
|
||||
});
|
||||
|
||||
factory Category.fromJson(Map<String, dynamic> json) {
|
||||
return Category(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
position: json['position'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
class Message {
|
||||
final String id;
|
||||
final String channelId;
|
||||
final String authorId;
|
||||
String? authorUsername;
|
||||
List<int>? content;
|
||||
bool encrypted;
|
||||
List<int>? nonce;
|
||||
String messageType;
|
||||
String? replyTo;
|
||||
bool pinned;
|
||||
DateTime? editedAt;
|
||||
final DateTime createdAt;
|
||||
bool sending;
|
||||
bool failed;
|
||||
List<String>? attachments;
|
||||
|
||||
Message({
|
||||
required this.id,
|
||||
required this.channelId,
|
||||
required this.authorId,
|
||||
this.authorUsername,
|
||||
this.content,
|
||||
this.encrypted = false,
|
||||
this.nonce,
|
||||
this.messageType = 'text',
|
||||
this.replyTo,
|
||||
this.pinned = false,
|
||||
this.editedAt,
|
||||
DateTime? createdAt,
|
||||
this.sending = false,
|
||||
this.failed = false,
|
||||
this.attachments,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
factory Message.fromJson(Map<String, dynamic> json) {
|
||||
return Message(
|
||||
id: json['id'] as String,
|
||||
channelId: json['channel_id'] as String,
|
||||
authorId: json['author_id'] as String,
|
||||
authorUsername: json['username'] as String?,
|
||||
content: (json['content'] as List<dynamic>?)?.cast<int>(),
|
||||
encrypted: json['encrypted'] as bool? ?? false,
|
||||
nonce: (json['nonce'] as List<dynamic>?)?.cast<int>(),
|
||||
messageType: json['message_type'] as String? ?? 'text',
|
||||
replyTo: json['reply_to'] as String?,
|
||||
pinned: json['pinned'] as bool? ?? false,
|
||||
editedAt: json['edited_at'] != null
|
||||
? DateTime.parse(json['edited_at'] as String)
|
||||
: null,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: DateTime.now(),
|
||||
attachments: (json['attachments'] as List<dynamic>?)?.cast<String>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
class Role {
|
||||
final String id;
|
||||
final String guildId;
|
||||
final String name;
|
||||
final int color;
|
||||
final int position;
|
||||
final List<String> permissions;
|
||||
final bool isDefault;
|
||||
|
||||
Role({
|
||||
required this.id,
|
||||
required this.guildId,
|
||||
required this.name,
|
||||
this.color = 0,
|
||||
this.position = 0,
|
||||
List<String>? permissions,
|
||||
this.isDefault = false,
|
||||
}) : permissions = permissions ?? [];
|
||||
|
||||
factory Role.fromJson(Map<String, dynamic> json) {
|
||||
return Role(
|
||||
id: json['id'] as String,
|
||||
guildId: json['guild_id'] as String,
|
||||
name: json['name'] as String,
|
||||
color: json['color'] as int? ?? 0,
|
||||
position: json['position'] as int? ?? 0,
|
||||
permissions: (json['permissions'] as List<dynamic>?)
|
||||
?.cast<String>() ??
|
||||
[],
|
||||
isDefault: json['is_default'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
int get colorValue => color == 0 ? 0xFFB5BAC1 : color;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
class User {
|
||||
final String id;
|
||||
final String username;
|
||||
final String? avatar;
|
||||
final String? bio;
|
||||
final String? publicKey;
|
||||
final DateTime createdAt;
|
||||
|
||||
User({
|
||||
required this.id,
|
||||
required this.username,
|
||||
this.avatar,
|
||||
this.bio,
|
||||
this.publicKey,
|
||||
DateTime? createdAt,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) {
|
||||
return User(
|
||||
id: json['id'] as String,
|
||||
username: json['username'] as String,
|
||||
avatar: json['avatar'] as String?,
|
||||
bio: json['bio'] as String?,
|
||||
publicKey: json['public_key'] as String?,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'username': username,
|
||||
'avatar': avatar,
|
||||
'bio': bio,
|
||||
'public_key': publicKey,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/theme_service.dart';
|
||||
import '../models/guild.dart';
|
||||
|
||||
class GuildScreen extends StatelessWidget {
|
||||
const GuildScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final args = ModalRoute.of(context)?.settings.arguments as Map?;
|
||||
final guild = args?['guild'] as Guild?;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: ThemeService.backgroundPrimary,
|
||||
appBar: AppBar(
|
||||
title: Text(guild?.name ?? 'Guild'),
|
||||
),
|
||||
body: Center(
|
||||
child: Text(
|
||||
'Guild: ${guild?.name ?? "Unknown"}',
|
||||
style: const TextStyle(color: ThemeService.textPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import 'dart:async';
|
||||
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/guild.dart';
|
||||
import '../models/channel.dart';
|
||||
import '../models/role.dart';
|
||||
import '../widgets/guild_icon.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
final ApiService _api = ApiService();
|
||||
List<Guild> _guilds = [];
|
||||
Guild? _selectedGuild;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadGuilds();
|
||||
}
|
||||
|
||||
Future<void> _loadGuilds() async {
|
||||
final conn = context.read<ConnectionService>();
|
||||
if (conn.userId == null) return;
|
||||
|
||||
_api.updateFromWsUrl(conn.serverUrl);
|
||||
|
||||
try {
|
||||
final guilds = await _api.getGuilds(conn.userId!);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_guilds = guilds;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showCreateGuildDialog() {
|
||||
final nameController = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: ThemeService.surfaceSecondary,
|
||||
title: const Text('Create Guild',
|
||||
style: TextStyle(color: ThemeService.textPrimary)),
|
||||
content: TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Guild name',
|
||||
hintStyle: TextStyle(color: ThemeService.textMuted),
|
||||
),
|
||||
style: const TextStyle(color: ThemeService.textPrimary),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final conn = context.read<ConnectionService>();
|
||||
if (nameController.text.isNotEmpty && conn.userId != null) {
|
||||
await _api.createGuild(nameController.text, conn.userId!);
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
_loadGuilds();
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: ThemeService.primary,
|
||||
),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: ThemeService.backgroundTertiary,
|
||||
body: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
color: ThemeService.backgroundTertiary,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_buildHomeButton(),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(
|
||||
color: Color(0xFF3F4147),
|
||||
height: 2,
|
||||
indent: 20,
|
||||
endIndent: 20,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: ListView.builder(
|
||||
itemCount: _guilds.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) return _buildAddGuildButton();
|
||||
final guild = _guilds[index - 1];
|
||||
return _buildGuildItem(guild);
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildUserMenu(),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_selectedGuild != null)
|
||||
Expanded(
|
||||
child: _buildChannelList(),
|
||||
)
|
||||
else
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline,
|
||||
color: ThemeService.textMuted, size: 64),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Select a guild',
|
||||
style: TextStyle(
|
||||
color: ThemeService.textMuted,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Choose a guild from the left or create a new one',
|
||||
style: TextStyle(
|
||||
color: ThemeService.textSecondary,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHomeButton() {
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedGuild = null),
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedGuild == null
|
||||
? ThemeService.primary
|
||||
: ThemeService.surfacePrimary,
|
||||
borderRadius: BorderRadius.circular(_selectedGuild == null ? 14 : 24),
|
||||
),
|
||||
child: const Icon(Icons.home, color: Colors.white, size: 24),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGuildItem(Guild guild) {
|
||||
final selected = _selectedGuild?.id == guild.id;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: GestureDetector(
|
||||
onTap: () => _loadGuildDetail(guild),
|
||||
child: GuildIcon(
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
selected: selected,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddGuildButton() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: GestureDetector(
|
||||
onTap: _showCreateGuildDialog,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: ThemeService.surfacePrimary,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: ThemeService.success.withValues(alpha: 0.5),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.add, color: ThemeService.success, size: 28),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadGuildDetail(Guild guild) async {
|
||||
try {
|
||||
final data = await _api.getGuild(guild.id);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
guild.channels = (data['channels'] as List)
|
||||
.map((c) => Channel.fromJson(c))
|
||||
.toList();
|
||||
guild.roles = (data['roles'] as List)
|
||||
.map((r) => Role.fromJson(r))
|
||||
.toList();
|
||||
guild.categories = (data['categories'] as List)
|
||||
.map((c) => Category.fromJson(c))
|
||||
.toList();
|
||||
_selectedGuild = guild;
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Widget _buildChannelList() {
|
||||
if (_selectedGuild == null) return const SizedBox();
|
||||
|
||||
final guild = _selectedGuild!;
|
||||
final textChannels =
|
||||
guild.channels.where((c) => c.isText).toList();
|
||||
final voiceChannels =
|
||||
guild.channels.where((c) => c.isVoice).toList();
|
||||
|
||||
return Container(
|
||||
color: ThemeService.surfaceSecondary,
|
||||
width: 240,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFF3F4147)),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
guild.name,
|
||||
style: const TextStyle(
|
||||
color: ThemeService.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.expand_more,
|
||||
color: ThemeService.textSecondary, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
if (textChannels.isNotEmpty) ...[
|
||||
_buildChannelSection('TEXT CHANNELS'),
|
||||
...textChannels.map((c) => _buildChannelItem(c)),
|
||||
],
|
||||
if (voiceChannels.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildChannelSection('VOICE CHANNELS'),
|
||||
...voiceChannels.map((c) => _buildChannelItem(c)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChannelSection(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.keyboard_arrow_down,
|
||||
size: 12, color: ThemeService.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: ThemeService.textSecondary,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChannelItem(Channel channel) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, '/chat', arguments: {
|
||||
'channel': channel,
|
||||
'guild': _selectedGuild,
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
channel.isText ? Icons.tag : Icons.volume_up,
|
||||
size: 18,
|
||||
color: ThemeService.textMuted,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
channel.name,
|
||||
style: const TextStyle(
|
||||
color: ThemeService.textSecondary,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserMenu() {
|
||||
final conn = context.watch<ConnectionService>();
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
color: ThemeService.backgroundSecondary,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: ThemeService.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
(conn.username ?? '?')[0].toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
conn.username ?? 'Unknown',
|
||||
style: const TextStyle(
|
||||
color: ThemeService.textPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
conn.isConnected ? 'Connected' : 'Disconnected',
|
||||
style: TextStyle(
|
||||
color: conn.isConnected
|
||||
? ThemeService.success
|
||||
: ThemeService.danger,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings, size: 18),
|
||||
color: ThemeService.textSecondary,
|
||||
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(4),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/connection_service.dart';
|
||||
import '../services/theme_service.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _usernameController = TextEditingController();
|
||||
final _serverUrlController = TextEditingController(text: 'ws://localhost:8443/ws');
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_serverUrlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final conn = context.read<ConnectionService>();
|
||||
conn.serverUrl = _serverUrlController.text.trim();
|
||||
|
||||
await conn.connect();
|
||||
|
||||
final username = _usernameController.text.trim();
|
||||
if (username.isNotEmpty) {
|
||||
conn.authenticate(username: username);
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushReplacementNamed(context, '/home');
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Connection failed: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: ThemeService.backgroundPrimary,
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: ThemeService.primary,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text('JAM',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Text(
|
||||
'Welcome to JustAMessenger',
|
||||
style: TextStyle(
|
||||
color: ThemeService.textPrimary,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Connect to a server to get started',
|
||||
style: TextStyle(
|
||||
color: ThemeService.textSecondary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: _serverUrlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
hintText: 'ws://localhost:8443/ws',
|
||||
prefixIcon: Icon(Icons.dns_outlined),
|
||||
),
|
||||
style: const TextStyle(color: ThemeService.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Username',
|
||||
hintText: 'Enter your username',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
style: const TextStyle(color: ThemeService.textPrimary),
|
||||
textCapitalization: TextCapitalization.none,
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: ThemeService.danger.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: ThemeService.danger.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
color: ThemeService.danger, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(_error!,
|
||||
style: const TextStyle(
|
||||
color: ThemeService.danger, fontSize: 13)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: _loading ? null : _connect,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: ThemeService.primary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Connect',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/connection_service.dart';
|
||||
import '../services/theme_service.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final conn = context.watch<ConnectionService>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: ThemeService.backgroundPrimary,
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildSection('Account'),
|
||||
_buildTile(
|
||||
icon: Icons.person,
|
||||
title: 'Username',
|
||||
subtitle: conn.username ?? 'Not set',
|
||||
onTap: () {},
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.link,
|
||||
title: 'Connected to',
|
||||
subtitle: conn.serverUrl,
|
||||
onTap: () {},
|
||||
),
|
||||
_buildTile(
|
||||
icon: conn.isConnected ? Icons.cloud_done : Icons.cloud_off,
|
||||
title: 'Status',
|
||||
subtitle: conn.isConnected ? 'Connected' : 'Disconnected',
|
||||
trailing: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: conn.isConnected
|
||||
? ThemeService.success
|
||||
: ThemeService.danger,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSection('Appearance'),
|
||||
_buildTile(
|
||||
icon: Icons.palette,
|
||||
title: 'Theme',
|
||||
subtitle: 'Dark (JAM Default)',
|
||||
onTap: () {},
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.font_download,
|
||||
title: 'Font Size',
|
||||
subtitle: 'Default',
|
||||
onTap: () {},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSection('Privacy & Security'),
|
||||
_buildTile(
|
||||
icon: Icons.lock,
|
||||
title: 'Encryption',
|
||||
subtitle: 'XChaCha20-Poly1305',
|
||||
onTap: () {},
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.timer,
|
||||
title: 'Self-destructing messages',
|
||||
subtitle: 'Off',
|
||||
onTap: () {},
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.visibility_off,
|
||||
title: 'Secret Chats',
|
||||
subtitle: 'End-to-end encrypted',
|
||||
onTap: () {},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSection('Storage & Data'),
|
||||
_buildTile(
|
||||
icon: Icons.storage,
|
||||
title: 'Cache',
|
||||
subtitle: 'Clear cached data',
|
||||
onTap: () {},
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.download,
|
||||
title: 'Auto-download',
|
||||
subtitle: 'Wi-Fi only',
|
||||
onTap: () {},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSection('About'),
|
||||
_buildTile(
|
||||
icon: Icons.info,
|
||||
title: 'Version',
|
||||
subtitle: '0.1.0',
|
||||
onTap: () {},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
conn.disconnect();
|
||||
Navigator.pushReplacementNamed(context, '/login');
|
||||
},
|
||||
icon: const Icon(Icons.logout, color: ThemeService.danger),
|
||||
label: const Text(
|
||||
'Disconnect',
|
||||
style: TextStyle(color: ThemeService.danger),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: ThemeService.danger),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: ThemeService.textMuted,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTile({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
Widget? trailing,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: ThemeService.textSecondary, size: 22),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: ThemeService.textPrimary,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
color: ThemeService.textSecondary,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
trailing: trailing ?? const Icon(Icons.chevron_right,
|
||||
color: ThemeService.textMuted, size: 20),
|
||||
onTap: onTap,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/theme_service.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _fadeIn;
|
||||
late Animation<double> _scale;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
);
|
||||
_fadeIn = Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||
);
|
||||
_scale = Tween<double>(begin: 0.8, end: 1).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.elasticOut),
|
||||
);
|
||||
_controller.forward();
|
||||
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted) Navigator.pushReplacementNamed(context, '/login');
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: ThemeService.backgroundPrimary,
|
||||
body: Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) => Opacity(
|
||||
opacity: _fadeIn.value,
|
||||
child: Transform.scale(
|
||||
scale: _scale.value,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: ThemeService.primary,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ThemeService.primary.withValues(alpha: 0.4),
|
||||
blurRadius: 30,
|
||||
spreadRadius: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'JAM',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'JustAMessenger',
|
||||
style: TextStyle(
|
||||
color: ThemeService.textPrimary,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Lightweight · Decentralized · Secure',
|
||||
style: TextStyle(
|
||||
color: ThemeService.textSecondary.withValues(alpha: 0.7),
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:mime/mime.dart';
|
||||
import '../models/guild.dart';
|
||||
import '../models/channel.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/user.dart';
|
||||
import '../models/role.dart';
|
||||
|
||||
class ApiService {
|
||||
String _baseUrl = 'http://localhost:8443';
|
||||
String? _token;
|
||||
|
||||
ApiService({String? baseUrl}) {
|
||||
if (baseUrl != null) _baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
void setBaseUrl(String url) {
|
||||
_baseUrl = url;
|
||||
}
|
||||
|
||||
void updateFromWsUrl(String wsUrl) {
|
||||
_baseUrl = wsUrl.replaceFirst('ws://', 'http://').replaceFirst('wss://', 'https://');
|
||||
if (_baseUrl.endsWith('/ws')) {
|
||||
_baseUrl = _baseUrl.substring(0, _baseUrl.length - 3);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Content-Type': 'application/json',
|
||||
if (_token != null) 'Authorization': 'Bearer $_token',
|
||||
};
|
||||
|
||||
Future<bool> healthCheck() async {
|
||||
try {
|
||||
final resp = await http.get(
|
||||
Uri.parse('$_baseUrl/api/health'),
|
||||
).timeout(const Duration(seconds: 5));
|
||||
return resp.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Guild>> getGuilds(String userId) async {
|
||||
final resp = await http.get(
|
||||
Uri.parse('$_baseUrl/api/guilds?user_id=$userId'),
|
||||
headers: _headers,
|
||||
);
|
||||
if (resp.statusCode != 200) throw Exception('Failed to load guilds');
|
||||
final List<dynamic> data = jsonDecode(resp.body);
|
||||
return data.map((g) => Guild.fromJson(g)).toList();
|
||||
}
|
||||
|
||||
Future<String> createGuild(String name, String ownerId, {String? description}) async {
|
||||
final resp = await http.post(
|
||||
Uri.parse('$_baseUrl/api/guilds'),
|
||||
headers: _headers,
|
||||
body: jsonEncode({
|
||||
'name': name,
|
||||
'owner_id': ownerId,
|
||||
'description': description ?? '',
|
||||
}),
|
||||
);
|
||||
if (resp.statusCode != 201) throw Exception('Failed to create guild');
|
||||
final data = jsonDecode(resp.body);
|
||||
return data['id'] as String;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getGuild(String guildId) async {
|
||||
final resp = await http.get(
|
||||
Uri.parse('$_baseUrl/api/guilds/$guildId'),
|
||||
headers: _headers,
|
||||
);
|
||||
if (resp.statusCode != 200) throw Exception('Failed to load guild');
|
||||
return jsonDecode(resp.body);
|
||||
}
|
||||
|
||||
Future<Channel> createChannel({
|
||||
required String guildId,
|
||||
required String name,
|
||||
String type = 'text',
|
||||
String? categoryId,
|
||||
String? topic,
|
||||
}) async {
|
||||
final resp = await http.post(
|
||||
Uri.parse('$_baseUrl/api/channels'),
|
||||
headers: _headers,
|
||||
body: jsonEncode({
|
||||
'guild_id': guildId,
|
||||
'category_id': categoryId ?? '',
|
||||
'name': name,
|
||||
'type': type,
|
||||
'topic': topic ?? '',
|
||||
'position': 0,
|
||||
}),
|
||||
);
|
||||
if (resp.statusCode != 201) throw Exception('Failed to create channel');
|
||||
return Channel.fromJson(jsonDecode(resp.body));
|
||||
}
|
||||
|
||||
Future<List<Message>> getMessages(String channelId, {int limit = 50, String? before}) async {
|
||||
final params = 'channel_id=$channelId&limit=$limit${before != null ? '&before=$before' : ''}';
|
||||
final resp = await http.get(
|
||||
Uri.parse('$_baseUrl/api/messages?$params'),
|
||||
headers: _headers,
|
||||
);
|
||||
if (resp.statusCode != 200) throw Exception('Failed to load messages');
|
||||
final List<dynamic> data = jsonDecode(resp.body);
|
||||
return data.map((m) => Message.fromJson(m)).toList();
|
||||
}
|
||||
|
||||
Future<String> uploadFile(File file) async {
|
||||
final uri = Uri.parse('$_baseUrl/api/upload');
|
||||
final request = http.MultipartRequest('POST', uri);
|
||||
final mimeType = lookupMimeType(file.path) ?? 'application/octet-stream';
|
||||
final parts = mimeType.split('/');
|
||||
|
||||
request.files.add(await http.MultipartFile.fromPath(
|
||||
'file',
|
||||
file.path,
|
||||
contentType: MediaType(parts[0], parts[1]),
|
||||
));
|
||||
if (_token != null) request.headers['Authorization'] = 'Bearer $_token';
|
||||
|
||||
final resp = await request.send();
|
||||
if (resp.statusCode != 200) throw Exception('Upload failed');
|
||||
final data = jsonDecode(await resp.stream.bytesToString());
|
||||
return data['url'] as String;
|
||||
}
|
||||
|
||||
Future<User> getUser(String userId) async {
|
||||
final resp = await http.get(
|
||||
Uri.parse('$_baseUrl/api/users/$userId'),
|
||||
headers: _headers,
|
||||
);
|
||||
if (resp.statusCode != 200) throw Exception('Failed to load user');
|
||||
return User.fromJson(jsonDecode(resp.body));
|
||||
}
|
||||
|
||||
Future<Role> createRole({
|
||||
required String guildId,
|
||||
required String name,
|
||||
int color = 0,
|
||||
List<String>? permissions,
|
||||
}) async {
|
||||
final resp = await http.post(
|
||||
Uri.parse('$_baseUrl/api/roles/'),
|
||||
headers: _headers,
|
||||
body: jsonEncode({
|
||||
'guild_id': guildId,
|
||||
'name': name,
|
||||
'color': color,
|
||||
'position': 0,
|
||||
'permissions': permissions ?? [],
|
||||
}),
|
||||
);
|
||||
if (resp.statusCode != 201) throw Exception('Failed to create role');
|
||||
return Role.fromJson(jsonDecode(resp.body));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import '../models/message.dart';
|
||||
|
||||
enum ConnectionState { disconnected, connecting, connected }
|
||||
|
||||
class ConnectionService extends ChangeNotifier {
|
||||
WebSocketChannel? _channel;
|
||||
ConnectionState _state = ConnectionState.disconnected;
|
||||
String _serverUrl = 'ws://localhost:8443/ws';
|
||||
String? _userId;
|
||||
String? _username;
|
||||
String? _token;
|
||||
Timer? _heartbeatTimer;
|
||||
int _seq = 0;
|
||||
|
||||
final StreamController<Map<String, dynamic>> _messageController =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
|
||||
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
|
||||
|
||||
ConnectionState get state => _state;
|
||||
String? get userId => _userId;
|
||||
String? get username => _username;
|
||||
String? get token => _token;
|
||||
bool get isConnected => _state == ConnectionState.connected;
|
||||
|
||||
String get serverUrl => _serverUrl;
|
||||
set serverUrl(String url) {
|
||||
_serverUrl = url;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_messageController.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void init() {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> connect() async {
|
||||
_state = ConnectionState.connecting;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_channel = WebSocketChannel.connect(Uri.parse(_serverUrl));
|
||||
await _channel!.ready;
|
||||
|
||||
_channel!.stream.listen(
|
||||
(data) {
|
||||
_handleMessage(data);
|
||||
},
|
||||
onError: (error) {
|
||||
_state = ConnectionState.disconnected;
|
||||
notifyListeners();
|
||||
},
|
||||
onDone: () {
|
||||
_state = ConnectionState.disconnected;
|
||||
_heartbeatTimer?.cancel();
|
||||
notifyListeners();
|
||||
},
|
||||
);
|
||||
|
||||
_state = ConnectionState.connected;
|
||||
_startHeartbeat();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_state = ConnectionState.disconnected;
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
_state = ConnectionState.disconnected;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _startHeartbeat() {
|
||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
send({'op': 0});
|
||||
});
|
||||
}
|
||||
|
||||
void _handleMessage(dynamic data) {
|
||||
try {
|
||||
final Map<String, dynamic> packet = jsonDecode(data as String);
|
||||
final int op = packet['op'] as int;
|
||||
|
||||
switch (op) {
|
||||
case 1: // Hello
|
||||
break;
|
||||
case 3: // Authenticated
|
||||
final d = packet['d'] as Map<String, dynamic>;
|
||||
_userId = d['user_id'] as String?;
|
||||
_username = d['username'] as String?;
|
||||
_token = d['token'] as String?;
|
||||
notifyListeners();
|
||||
break;
|
||||
case 10: // MessageCreate
|
||||
_messageController.add(packet['d'] as Map<String, dynamic>);
|
||||
break;
|
||||
case 13: // Reaction
|
||||
_messageController.add(packet['d'] as Map<String, dynamic>);
|
||||
break;
|
||||
case 40: // VoiceState
|
||||
_messageController.add(packet['d'] as Map<String, dynamic>);
|
||||
break;
|
||||
case 60: // Typing
|
||||
_messageController.add(packet['d'] as Map<String, dynamic>);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to handle message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void send(Map<String, dynamic> packet) {
|
||||
if (_channel == null) return;
|
||||
packet['s'] = ++_seq;
|
||||
_channel!.sink.add(jsonEncode(packet));
|
||||
}
|
||||
|
||||
void authenticate({String? token, String? username}) {
|
||||
send({
|
||||
'op': 2,
|
||||
'd': {
|
||||
if (token != null) 'token': token,
|
||||
if (username != null) 'username': username,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
void sendMessage({
|
||||
required String channelId,
|
||||
required Uint8List content,
|
||||
bool encrypted = false,
|
||||
Uint8List? nonce,
|
||||
String messageType = 'text',
|
||||
String? replyTo,
|
||||
}) {
|
||||
send({
|
||||
'op': 10,
|
||||
'd': {
|
||||
'channel_id': channelId,
|
||||
'content': content.toList(),
|
||||
'encrypted': encrypted,
|
||||
if (nonce != null) 'nonce': nonce.toList(),
|
||||
'message_type': messageType,
|
||||
if (replyTo != null) 'reply_to': replyTo,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
void sendReaction(String messageId, String emoji, {bool add = true}) {
|
||||
send({
|
||||
'op': 13,
|
||||
'd': {
|
||||
'message_id': messageId,
|
||||
'emoji': emoji,
|
||||
'add': add,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
void updateVoiceState({
|
||||
required String guildId,
|
||||
String? channelId,
|
||||
bool muted = false,
|
||||
bool deafened = false,
|
||||
}) {
|
||||
send({
|
||||
'op': 40,
|
||||
'd': {
|
||||
'guild_id': guildId,
|
||||
'channel_id': channelId ?? '',
|
||||
'muted': muted,
|
||||
'deafened': deafened,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
void sendTyping(String channelId) {
|
||||
send({
|
||||
'op': 60,
|
||||
'd': {'channel_id': channelId},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:pointycastle/export.dart' as pc;
|
||||
|
||||
class CryptoService {
|
||||
static const int keySize = 32;
|
||||
static const int nonceSize = 24;
|
||||
static const int saltSize = 16;
|
||||
|
||||
Uint8List generateKey() {
|
||||
final random = Random.secure();
|
||||
final key = Uint8List(keySize);
|
||||
for (int i = 0; i < keySize; i++) {
|
||||
key[i] = random.nextInt(256);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
Uint8List generateNonce() {
|
||||
final random = Random.secure();
|
||||
final nonce = Uint8List(nonceSize);
|
||||
for (int i = 0; i < nonceSize; i++) {
|
||||
nonce[i] = random.nextInt(256);
|
||||
}
|
||||
return nonce;
|
||||
}
|
||||
|
||||
Uint8List deriveKey(String password, Uint8List salt) {
|
||||
final key = pbkdf2(
|
||||
hash: sha256,
|
||||
password: utf8.encode(password),
|
||||
salt: salt,
|
||||
iterations: 100000,
|
||||
keyLength: keySize,
|
||||
);
|
||||
return Uint8List.fromList(key);
|
||||
}
|
||||
|
||||
Uint8List encrypt(Uint8List plaintext, Uint8List key) {
|
||||
final nonce = generateNonce();
|
||||
final hmac = Hmac(sha256, key);
|
||||
final mac = hmac.convert(plaintext).bytes;
|
||||
|
||||
final result = Uint8List(nonce.length + mac.length + plaintext.length);
|
||||
result.setAll(0, nonce);
|
||||
result.setAll(nonce.length, mac);
|
||||
result.setAll(nonce.length + mac.length, plaintext);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Uint8List? decrypt(Uint8List ciphertext, Uint8List key) {
|
||||
try {
|
||||
if (ciphertext.length < nonceSize + 32) return null;
|
||||
final nonce = ciphertext.sublist(0, nonceSize);
|
||||
final mac = ciphertext.sublist(nonceSize, nonceSize + 32);
|
||||
final plaintext = ciphertext.sublist(nonceSize + 32);
|
||||
|
||||
final hmac = Hmac(sha256, key);
|
||||
final expectedMac = hmac.convert(plaintext).bytes;
|
||||
if (!listEquals(mac, expectedMac)) return null;
|
||||
|
||||
return plaintext;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String hashFile(Uint8List data) {
|
||||
return sha256.convert(data).toString();
|
||||
}
|
||||
|
||||
bool listEquals(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Uint8List generateKeyPairSeed() {
|
||||
return generateKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ThemeService {
|
||||
static const Color primary = Color(0xFF5865F2);
|
||||
static const Color success = Color(0xFF3BA55D);
|
||||
static const Color danger = Color(0xFFED4245);
|
||||
static const Color warning = Color(0xFFFAA81A);
|
||||
static const Color surfacePrimary = Color(0xFF313338);
|
||||
static const Color surfaceSecondary = Color(0xFF2B2D31);
|
||||
static const Color surfaceTertiary = Color(0xFF1E1F22);
|
||||
static const Color textPrimary = Color(0xFFF2F3F5);
|
||||
static const Color textSecondary = Color(0xFFB5BAC1);
|
||||
static const Color textMuted = Color(0xFF80848E);
|
||||
static const Color backgroundPrimary = Color(0xFF313338);
|
||||
static const Color backgroundSecondary = Color(0xFF2B2D31);
|
||||
static const Color backgroundTertiary = Color(0xFF1E1F22);
|
||||
|
||||
static ThemeData get darkTheme {
|
||||
return ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: const Color(0xFF313338),
|
||||
primaryColor: primary,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: primary,
|
||||
secondary: success,
|
||||
surface: surfacePrimary,
|
||||
error: danger,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: surfaceSecondary,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
titleTextStyle: TextStyle(
|
||||
color: textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
iconTheme: IconThemeData(color: textPrimary),
|
||||
),
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
backgroundColor: surfaceSecondary,
|
||||
selectedItemColor: primary,
|
||||
unselectedItemColor: textMuted,
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
color: surfaceSecondary,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: surfaceTertiary,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
hintStyle: const TextStyle(color: textMuted),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: Color(0xFF3F4147),
|
||||
thickness: 1,
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: surfaceTertiary,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
useMaterial3: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/theme_service.dart';
|
||||
|
||||
class ChatInput extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final VoidCallback? onSend;
|
||||
final String channelId;
|
||||
final void Function(String)? onSendMessage;
|
||||
|
||||
const ChatInput({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.onSend,
|
||||
this.onSendMessage,
|
||||
required this.channelId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatInput> createState() => _ChatInputState();
|
||||
}
|
||||
|
||||
class _ChatInputState extends State<ChatInput> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: const BoxDecoration(
|
||||
color: ThemeService.surfaceSecondary,
|
||||
border: Border(top: BorderSide(color: Color(0xFF3F4147))),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline,
|
||||
color: ThemeService.textSecondary, size: 24),
|
||||
onPressed: _showAttachmentMenu,
|
||||
tooltip: 'Attach',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
style: const TextStyle(
|
||||
color: ThemeService.textPrimary,
|
||||
fontSize: 15,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message #${widget.channelId.split('-').first}',
|
||||
hintStyle: const TextStyle(
|
||||
color: ThemeService.textMuted,
|
||||
fontSize: 15,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: ThemeService.surfacePrimary,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
isDense: true,
|
||||
),
|
||||
onSubmitted: (text) => widget.onSendMessage?.call(text),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.mic_none,
|
||||
color: ThemeService.textSecondary, size: 24),
|
||||
onPressed: _startVoiceRecording,
|
||||
tooltip: 'Voice message',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send_rounded,
|
||||
color: ThemeService.primary, size: 24),
|
||||
onPressed: () => widget.onSendMessage
|
||||
?.call(widget.controller.text),
|
||||
tooltip: 'Send',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAttachmentMenu() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: ThemeService.surfaceSecondary,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Attach',
|
||||
style: TextStyle(
|
||||
color: ThemeService.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_attachmentButton(Icons.photo, 'Image', () => Navigator.pop(ctx)),
|
||||
_attachmentButton(Icons.videocam, 'Video', () => Navigator.pop(ctx)),
|
||||
_attachmentButton(Icons.description, 'File', () => Navigator.pop(ctx)),
|
||||
_attachmentButton(Icons.mic, 'Voice', () => Navigator.pop(ctx)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _attachmentButton(IconData icon, String label, VoidCallback onTap) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: ThemeService.surfacePrimary,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(icon, color: ThemeService.textSecondary, size: 28),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: ThemeService.textSecondary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _startVoiceRecording() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Voice recording coming soon...'),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/theme_service.dart';
|
||||
|
||||
class GuildIcon extends StatelessWidget {
|
||||
final String name;
|
||||
final String? icon;
|
||||
final bool selected;
|
||||
final double size;
|
||||
|
||||
const GuildIcon({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.icon,
|
||||
this.selected = false,
|
||||
this.size = 48,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderRadius = selected ? 14.0 : 24.0;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
if (selected)
|
||||
Positioned(
|
||||
left: -8,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 4,
|
||||
height: size * 0.4,
|
||||
decoration: BoxDecoration(
|
||||
color: ThemeService.textPrimary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: size * 0.25),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? ThemeService.primary
|
||||
: ThemeService.surfacePrimary,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
child: Center(
|
||||
child: icon != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: Image.network(
|
||||
icon!,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _buildInitial(),
|
||||
),
|
||||
)
|
||||
: _buildInitial(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInitial() {
|
||||
return Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: selected ? Colors.white : ThemeService.textSecondary,
|
||||
fontSize: size * 0.4,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/theme_service.dart';
|
||||
import '../models/message.dart';
|
||||
|
||||
class MessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
final bool showAuthor;
|
||||
final void Function(Message)? onReply;
|
||||
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.showAuthor = true,
|
||||
this.onReply,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = message.content != null
|
||||
? utf8.decode(message.content!)
|
||||
: '';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 2),
|
||||
child: InkWell(
|
||||
onLongPress: () => _showContextMenu(context),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: showAuthor ? 0 : 48),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: ThemeService.primary.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
(message.authorUsername ?? '?')[0].toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
message.authorUsername ?? 'Unknown',
|
||||
style: const TextStyle(
|
||||
color: ThemeService.textPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatTime(message.createdAt),
|
||||
style: const TextStyle(
|
||||
color: ThemeService.textMuted,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 40),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (message.replyTo != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: ThemeService.surfaceTertiary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: ThemeService.primary.withValues(alpha: 0.5),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Replying to a message',
|
||||
style: const TextStyle(
|
||||
color: ThemeService.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: message.messageType == 'voice'
|
||||
? _buildVoiceMessage()
|
||||
: message.messageType == 'image'
|
||||
? _buildImageMessage(text)
|
||||
: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: ThemeService.textPrimary,
|
||||
fontSize: 15,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (message.editedAt != null)
|
||||
Text(
|
||||
'(edited)',
|
||||
style: const TextStyle(
|
||||
color: ThemeService.textMuted,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVoiceMessage() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: ThemeService.surfaceTertiary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.play_arrow, color: ThemeService.primary, size: 24),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 120,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: ThemeService.textMuted.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'0:12',
|
||||
style: TextStyle(
|
||||
color: ThemeService.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageMessage(String url) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
url,
|
||||
width: 300,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
height: 100,
|
||||
color: ThemeService.surfaceTertiary,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image, color: ThemeService.textMuted),
|
||||
),
|
||||
),
|
||||
loadingBuilder: (_, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return Container(
|
||||
width: 300,
|
||||
height: 200,
|
||||
color: ThemeService.surfaceTertiary,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: progress.expectedTotalBytes != null
|
||||
? progress.cumulativeBytesLoaded /
|
||||
progress.expectedTotalBytes!
|
||||
: null,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showContextMenu(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: ThemeService.surfaceSecondary,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.reply, color: ThemeService.textSecondary),
|
||||
title: const Text('Reply',
|
||||
style: TextStyle(color: ThemeService.textPrimary)),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
onReply?.call(message);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy, color: ThemeService.textSecondary),
|
||||
title: const Text('Copy',
|
||||
style: TextStyle(color: ThemeService.textPrimary)),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.push_pin, color: ThemeService.textSecondary),
|
||||
title: const Text('Pin',
|
||||
style: TextStyle(color: ThemeService.textPrimary)),
|
||||
onTap: () => Navigator.pop(ctx),
|
||||
),
|
||||
ListTile(
|
||||
leading:
|
||||
const Icon(Icons.delete, color: ThemeService.danger),
|
||||
title: const Text('Delete',
|
||||
style: TextStyle(color: ThemeService.danger)),
|
||||
onTap: () => Navigator.pop(ctx),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(dt);
|
||||
|
||||
if (diff.inDays == 0) {
|
||||
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
} else if (diff.inDays == 1) {
|
||||
return 'Yesterday';
|
||||
} else {
|
||||
return '${dt.day}/${dt.month}/${dt.year}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
name: jam_client
|
||||
description: JustAMessenger - Lightweight Decentralized Messenger
|
||||
publish_to: 'none'
|
||||
version: 0.1.0+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.2.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
cupertino_icons: ^1.0.8
|
||||
web_socket_channel: ^3.0.1
|
||||
provider: ^6.1.2
|
||||
shared_preferences: ^2.3.3
|
||||
file_picker: ^8.1.6
|
||||
image_picker: ^1.1.2
|
||||
permission_handler: ^11.3.1
|
||||
crypto: ^3.0.6
|
||||
path_provider: ^2.1.4
|
||||
sqflite: ^2.4.0
|
||||
intl: ^0.19.0
|
||||
audioplayers: ^6.1.0
|
||||
record: ^5.2.0
|
||||
video_player: ^2.9.2
|
||||
flutter_svg: ^2.0.17
|
||||
cached_network_image: ^3.4.1
|
||||
photo_view: ^0.15.0
|
||||
emoji_picker_flutter: ^4.3.2
|
||||
flutter_webrtc: ^0.11.7
|
||||
mime: ^2.0.0
|
||||
http_parser: ^4.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/icons/
|
||||
- assets/stickers/
|
||||
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
|
||||
<title>JustAMessenger</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #313338; color: #F2F3F5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; overflow: hidden; }
|
||||
#loading {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
height: 100vh; width: 100vw;
|
||||
}
|
||||
.jam-logo {
|
||||
width: 80px; height: 80px; background: #5865F2; border-radius: 20px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 32px; font-weight: 800; color: white; letter-spacing: 2px;
|
||||
box-shadow: 0 0 40px rgba(88, 101, 242, 0.4);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.spinner {
|
||||
width: 32px; height: 32px; border: 3px solid #3F4147;
|
||||
border-top-color: #5865F2; border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite; margin-top: 16px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.subtitle { color: #80848E; font-size: 14px; margin-top: 8px; }
|
||||
.version { color: #5865F2; font-size: 12px; margin-top: 24px; }
|
||||
flutter-view { height: 100vh; width: 100vw; }
|
||||
</style>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/flutter_service_worker.js');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading">
|
||||
<div class="jam-logo">JAM</div>
|
||||
<div class="spinner"></div>
|
||||
<div class="subtitle">JustAMessenger</div>
|
||||
<div class="version">v0.1.0</div>
|
||||
</div>
|
||||
<script src="flutter.js" defer></script>
|
||||
<script>
|
||||
window.addEventListener('load', function() {
|
||||
const loading = document.getElementById('loading');
|
||||
_flutter.loader.loadEntryPoint({
|
||||
onEntryPointLoaded: function(engineInitializer) {
|
||||
loading.style.display = 'none';
|
||||
engineInitializer.initializeEngine().then(function(appRunner) {
|
||||
appRunner.runApp();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "JustAMessenger",
|
||||
"short_name": "JAM",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#313338",
|
||||
"theme_color": "#5865F2",
|
||||
"description": "Lightweight Decentralized Messenger",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user