=== СТРУКТУРА ПРОЕКТА ===
android/settings.gradle [750 B]
android/app/build.gradle [1,395 B]
android/app/src/main/res/values/styles.xml [412 B]
android/app/src/main/res/xml/network_security_config.xml [583 B]
android/app/src/main/res/xml/file_paths.xml [245 B]
android/app/src/main/res/values-night/styles.xml [412 B]
android/app/src/main/res/drawable/launch_background.xml [180 B]
android/app/src/main/AndroidManifest.xml [6,894 B]
android/app/src/main/kotlin/ru/pluschat/app/MainActivity.kt [119 B]
android/build.gradle [342 B]
android/gradle.properties [80 B]
android/gradle/wrapper/gradle-wrapper.properties [203 B]
lib/widgets/chat_tile.dart [4,912 B]
lib/widgets/message_bubble.dart [11,978 B]
lib/screens/chats_screen.dart [7,390 B]
lib/screens/setup_screen.dart [4,595 B]
lib/screens/home_screen.dart [8,432 B]
lib/screens/chat_screen.dart [11,718 B]
lib/screens/verify_screen.dart [5,876 B]
lib/screens/login_screen.dart [5,883 B]
lib/screens/register_screen.dart [7,134 B]
lib/services/auth_service.dart [3,442 B]
lib/services/api_service.dart [11,623 B]
lib/models/chat.dart [6,855 B]
lib/models/user.dart [6,595 B]
lib/models/message.dart [10,341 B]
lib/main.dart [2,084 B]
pubspec.yaml [737 B]
assets/icon.png [1,647,016 B]
.github/workflows/generate-icons.yml [762 B]
.github/workflows/build.yml [876 B]
================================================================================
=== СОДЕРЖИМОЕ ФАЙЛОВ ===
================================================================================
### ФАЙЛ: android/settings.gradle
--------------------------------------------------------------------------------
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.1.0" apply false
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
}
include ":app"
### ФАЙЛ: android/app/build.gradle
--------------------------------------------------------------------------------
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
android {
namespace "ru.pluschat.app"
compileSdk 34
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId "ru.pluschat.app"
minSdk 21
targetSdk 34
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
release {
signingConfig signingConfigs.debug
}
}
}
flutter {
source '../..'
}
dependencies {}
### ФАЙЛ: android/app/src/main/res/values/styles.xml
--------------------------------------------------------------------------------
### ФАЙЛ: android/app/src/main/res/xml/network_security_config.xml
--------------------------------------------------------------------------------
10.0.2.2
localhost
### ФАЙЛ: android/app/src/main/res/xml/file_paths.xml
--------------------------------------------------------------------------------
### ФАЙЛ: android/app/src/main/res/values-night/styles.xml
--------------------------------------------------------------------------------
### ФАЙЛ: android/app/src/main/res/drawable/launch_background.xml
--------------------------------------------------------------------------------
### ФАЙЛ: android/app/src/main/AndroidManifest.xml
--------------------------------------------------------------------------------
### ФАЙЛ: android/app/src/main/kotlin/ru/pluschat/app/MainActivity.kt
--------------------------------------------------------------------------------
package ru.pluschat.app
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()
### ФАЙЛ: android/build.gradle
--------------------------------------------------------------------------------
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
### ФАЙЛ: android/gradle.properties
--------------------------------------------------------------------------------
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true
### ФАЙЛ: android/gradle/wrapper/gradle-wrapper.properties
--------------------------------------------------------------------------------
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip
### ФАЙЛ: lib/widgets/chat_tile.dart
--------------------------------------------------------------------------------
import 'package:flutter/material.dart';
import '../models/chat.dart';
/// Виджет для отображения чата в списке
class ChatTile extends StatelessWidget {
final Chat chat;
final VoidCallback onTap;
final VoidCallback? onLongPress;
const ChatTile({
super.key,
required this.chat,
required this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
return ListTile(
onTap: onTap,
onLongPress: onLongPress,
leading: _buildAvatar(context),
title: _buildTitle(context),
subtitle: _buildSubtitle(context),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
);
}
/// Аватар чата
Widget _buildAvatar(BuildContext context) {
return Stack(
children: [
CircleAvatar(
radius: 28,
backgroundColor: Theme.of(context).primaryColor,
backgroundImage: chat.avatarUrl != null
? NetworkImage(chat.avatarUrl!)
: null,
child: chat.avatarUrl == null
? Text(
chat.initial,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
)
: null,
),
// Индикатор онлайн для личных чатов
if (chat.type == ChatType.private && chat.isCompanionOnline)
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
],
);
}
/// Заголовок чата (имя + время)
Widget _buildTitle(BuildContext context) {
return Row(
children: [
// Иконка верификации
if (chat.isVerified)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(
Icons.verified,
size: 16,
color: Theme.of(context).primaryColor,
),
),
// Имя чата
Expanded(
child: Text(
chat.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
),
const SizedBox(width: 8),
// Время последнего сообщения
Text(
chat.lastMessageTime,
style: TextStyle(
fontSize: 12,
color: chat.unreadCount > 0
? Theme.of(context).primaryColor
: Colors.grey[600],
),
),
],
);
}
/// Подзаголовок (последнее сообщение + счётчик непрочитанных)
Widget _buildSubtitle(BuildContext context) {
return Row(
children: [
// Индикатор заглушенного чата
if (chat.isMuted)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(
Icons.volume_off,
size: 14,
color: Colors.grey[600],
),
),
// Текст последнего сообщения
Expanded(
child: Text(
chat.lastMessage ?? 'Нет сообщений',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
),
),
),
const SizedBox(width: 8),
// Счётчик непрочитанных
if (chat.unreadCount > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
decoration: BoxDecoration(
color: chat.isMuted ? Colors.grey : Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(12),
),
child: Text(
chat.unreadCount > 99 ? '99+' : '${chat.unreadCount}',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
);
}
}
### ФАЙЛ: lib/widgets/message_bubble.dart
--------------------------------------------------------------------------------
import 'package:flutter/material.dart';
import '../models/message.dart';
/// Виджет для отображения сообщения в чате
class MessageBubble extends StatelessWidget {
final Message message;
final bool isMe;
final bool showSenderName;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
const MessageBubble({
super.key,
required this.message,
required this.isMe,
this.showSenderName = false,
this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 8),
child: Column(
crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
// Имя отправителя (для групповых чатов)
if (showSenderName && !isMe && message.sender != null)
Padding(
padding: const EdgeInsets.only(left: 12, bottom: 2),
child: Text(
message.sender!.displayName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _getSenderColor(message.senderId),
),
),
),
// Пузырь сообщения
Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: _getBackgroundColor(context),
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isMe ? 16 : 4),
bottomRight: Radius.circular(isMe ? 4 : 16),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Ответ на сообщение
if (message.replyToMessage != null)
_buildReply(context),
// Контент сообщения
_buildContent(context),
// Вложения
if (message.hasAttachments)
_buildAttachments(context),
// Время и статусы
_buildFooter(context),
// Реакции
if (message.reactions.isNotEmpty)
_buildReactions(context),
],
),
),
],
),
),
);
}
/// Цвет фона пузыря
Color _getBackgroundColor(BuildContext context) {
if (message.isSending) {
return Colors.grey.withOpacity(0.3);
}
if (message.isFailed) {
return Colors.red.withOpacity(0.1);
}
return isMe
? const Color(0xFFDCF8C6)
: Theme.of(context).colorScheme.surfaceVariant;
}
/// Цвет имени отправителя
Color _getSenderColor(String senderId) {
final colors = [
Colors.red,
Colors.blue,
Colors.green,
Colors.purple,
Colors.orange,
Colors.teal,
Colors.pink,
Colors.indigo,
];
final index = senderId.hashCode.abs() % colors.length;
return colors[index];
}
/// Ответ на сообщение
Widget _buildReply(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(8, 8, 8, 0),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border(
left: BorderSide(
color: Theme.of(context).primaryColor,
width: 3,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message.replyToMessage!.sender?.displayName ?? 'Пользователь',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).primaryColor,
),
),
const SizedBox(height: 2),
Text(
message.replyToMessage!.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: Colors.grey[700],
),
),
],
),
);
}
/// Основной контент сообщения
Widget _buildContent(BuildContext context) {
// Если это не текст, показываем тип
if (message.type != MessageType.text && message.content.isEmpty) {
return Padding(
padding: const EdgeInsets.all(12),
child: Text(
message.type.label,
style: const TextStyle(
fontStyle: FontStyle.italic,
color: Colors.grey,
),
),
);
}
if (message.content.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: SelectableText(
message.content,
style: const TextStyle(fontSize: 15, height: 1.4),
),
);
}
/// Вложения (фото, видео, файлы)
Widget _buildAttachments(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: message.attachments.map((att) {
return Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(_getAttachmentIcon(att.fileType), size: 24),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
att.fileName ?? att.fileType,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13),
),
if (att.fileSize != null)
Text(
att.fileSizeFormatted,
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
],
),
),
],
),
);
}).toList(),
),
);
}
/// Иконка для типа вложения
IconData _getAttachmentIcon(String fileType) {
switch (fileType) {
case 'photo':
return Icons.image;
case 'video':
return Icons.videocam;
case 'voice':
return Icons.mic;
case 'video_note':
return Icons.video_camera_back;
case 'sticker':
return Icons.emoji_emotions;
default:
return Icons.attach_file;
}
}
/// Футер с временем и статусами
Widget _buildFooter(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Индикатор отправки
if (message.isSending)
const Padding(
padding: EdgeInsets.only(right: 4),
child: SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
// Индикатор ошибки
if (message.isFailed)
const Padding(
padding: EdgeInsets.only(right: 4),
child: Icon(Icons.error, size: 14, color: Colors.red),
),
// Метка "отредактировано"
if (message.edited)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(
'ред.',
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
),
// Время
Text(
message.timeFormatted,
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
// Статусы доставки (только для своих сообщений)
if (isMe) ...[
const SizedBox(width: 4),
Icon(
_getStatusIcon(),
size: 14,
color: Colors.grey[600],
),
],
],
),
);
}
/// Иконка статуса сообщения
IconData _getStatusIcon() {
if (message.isSending) return Icons.access_time;
if (message.isFailed) return Icons.error;
if (message.id > 0) return Icons.done_all;
return Icons.done;
}
/// Реакции на сообщение
Widget _buildReactions(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(8, 0, 8, 8),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.withOpacity(0.3)),
),
child: Wrap(
spacing: 4,
runSpacing: 2,
children: message.reactions.map((reaction) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: reaction.hasMyReaction
? Theme.of(context).primaryColor.withOpacity(0.2)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
border: reaction.hasMyReaction
? Border.all(color: Theme.of(context).primaryColor, width: 1)
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(reaction.emoji, style: const TextStyle(fontSize: 14)),
const SizedBox(width: 2),
Text(
'${reaction.count}',
style: TextStyle(
fontSize: 12,
fontWeight: reaction.hasMyReaction
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
);
}).toList(),
),
);
}
}
### ФАЙЛ: lib/screens/chats_screen.dart
--------------------------------------------------------------------------------
import 'dart:async';
import 'package:flutter/material.dart';
import '../services/api_service.dart';
import '../services/auth_service.dart';
import '../models/chat.dart';
import '../widgets/chat_tile.dart';
import 'chat_screen.dart';
import 'login_screen.dart';
class ChatsScreen extends StatefulWidget {
const ChatsScreen({super.key});
@override
State createState() => _ChatsScreenState();
}
class _ChatsScreenState extends State {
List _chats = [];
bool _loading = true;
Timer? _pollTimer;
@override
void initState() {
super.initState();
_loadChats();
_startPolling();
}
void _startPolling() {
_pollTimer = Timer.periodic(const Duration(seconds: 3), (_) {
if (mounted) _loadChats(silent: true);
});
}
Future _loadChats({bool silent = false}) async {
try {
if (!silent) setState(() => _loading = true);
final chats = await ApiService.getChats();
if (mounted) {
setState(() {
_chats = chats;
_loading = false;
});
}
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future _logout() async {
await ApiService.logout();
await AuthService.logout();
if (!mounted) return;
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => const LoginScreen()),
(route) => false,
);
}
@override
void dispose() {
_pollTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Чаты'),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
// TODO: Поиск чатов
},
),
IconButton(
icon: const Icon(Icons.logout),
onPressed: _logout,
tooltip: 'Выйти',
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _chats.isEmpty
? _buildEmptyState()
: RefreshIndicator(
onRefresh: _loadChats,
child: ListView.separated(
itemCount: _chats.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final chat = _chats[index];
return ChatTile(
chat: chat,
onTap: () => _openChat(chat),
onLongPress: () => _showChatOptions(chat),
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: _showNewChatDialog,
child: const Icon(Icons.edit),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.chat_bubble_outline,
size: 80,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'У вас пока нет чатов',
style: TextStyle(
color: Colors.grey[600],
fontSize: 16,
),
),
const SizedBox(height: 8),
Text(
'Нажмите + чтобы начать общение',
style: TextStyle(
color: Colors.grey[500],
fontSize: 14,
),
),
],
),
);
}
void _openChat(Chat chat) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatScreen(chat: chat),
),
).then((_) => _loadChats());
}
void _showChatOptions(Chat chat) {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.volume_off),
title: Text(chat.isMuted ? 'Включить уведомления' : 'Отключить уведомления'),
onTap: () {
Navigator.pop(ctx);
// TODO: Toggle mute
},
),
ListTile(
leading: const Icon(Icons.archive),
title: const Text('В архив'),
onTap: () {
Navigator.pop(ctx);
// TODO: Archive chat
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('Удалить чат', style: TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(ctx);
// TODO: Delete chat
},
),
],
),
),
);
}
void _showNewChatDialog() {
final controller = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Новый чат'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'ID пользователя',
hintText: 'usr_...',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
const Text(
'Введите ID пользователя для создания личного чата',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () async {
final id = controller.text.trim();
if (id.isEmpty) return;
Navigator.pop(ctx);
final res = await ApiService.createChat(
type: 'private',
members: [id],
);
if (res['success'] == true) {
await _loadChats();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Чат создан')),
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(res['error'] ?? 'Ошибка создания чата'),
backgroundColor: Colors.red,
),
);
}
}
},
child: const Text('Создать'),
),
],
),
);
}
}
### ФАЙЛ: lib/screens/setup_screen.dart
--------------------------------------------------------------------------------
import 'package:flutter/material.dart';
import '../services/api_service.dart';
import 'chats_screen.dart';
class SetupScreen extends StatefulWidget {
const SetupScreen({super.key});
@override
State createState() => _SetupScreenState();
}
class _SetupScreenState extends State {
final _firstCtrl = TextEditingController();
final _lastCtrl = TextEditingController();
final _userCtrl = TextEditingController();
bool _loading = false;
bool _usernameAvailable = true;
Future _checkUsername() async {
final u = _userCtrl.text.trim();
if (u.length < 3) return;
final res = await ApiService.checkUsername(u);
if (mounted) setState(() => _usernameAvailable = res['available'] == true);
}
Future _save() async {
if (_firstCtrl.text.isEmpty) {
_showError('Укажите имя');
return;
}
if (!_usernameAvailable) {
_showError('Username занят');
return;
}
setState(() => _loading = true);
try {
final res = await ApiService.setupProfile(
username: _userCtrl.text.trim(),
firstName: _firstCtrl.text.trim(),
lastName: _lastCtrl.text.trim(),
);
if (res['success'] == true && mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const ChatsScreen()),
);
} else {
_showError(res['error'] ?? 'Ошибка');
}
} catch (e) {
_showError('Ошибка сети: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _showError(String msg) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(msg), backgroundColor: Colors.red),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Настройка профиля')),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Почти готово!',
style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 8),
const Text('Заполните профиль, чтобы друзья могли вас найти'),
const SizedBox(height: 32),
TextField(
controller: _firstCtrl,
decoration: const InputDecoration(
labelText: 'Имя *',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: _lastCtrl,
decoration: const InputDecoration(
labelText: 'Фамилия',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: _userCtrl,
onChanged: (_) => _checkUsername(),
decoration: InputDecoration(
labelText: 'Username (@)',
prefixText: '@',
border: const OutlineInputBorder(),
errorText: _usernameAvailable || _userCtrl.text.length < 3
? null
: 'Этот username занят',
suffixIcon: _userCtrl.text.length >= 3
? Icon(_usernameAvailable
? Icons.check_circle
: Icons.cancel,
color: _usernameAvailable ? Colors.green : Colors.red)
: null,
),
),
const SizedBox(height: 32),
FilledButton(
onPressed: _loading ? null : _save,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16)),
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Начать общение',
style: TextStyle(fontSize: 16)),
),
],
),
),
),
);
}
}
### ФАЙЛ: lib/screens/home_screen.dart
--------------------------------------------------------------------------------
import 'package:flutter/material.dart';
import '../services/api_service.dart';
import '../services/auth_service.dart';
import '../models/user.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State createState() => _HomeScreenState();
}
class _HomeScreenState extends State {
User? _currentUser;
bool _loading = true;
String _errorMessage = '';
@override
void initState() {
super.initState();
_loadUser();
}
Future _loadUser() async {
try {
final user = await ApiService.getMe();
if (mounted) {
setState(() {
_currentUser = user;
_loading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_loading = false;
});
}
}
}
Future _logout() async {
final confirm = await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Выход'),
content: const Text('Вы уверены, что хотите выйти?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Отмена'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Выйти', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirm == true && mounted) {
await AuthService.logout();
if (mounted) {
Navigator.pushNamedAndRemoveUntil(context, '/login', (route) => false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Плюс Чат'),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
// TODO: Поиск
},
),
PopupMenuButton(
onSelected: (value) {
if (value == 'profile') {
// TODO: Профиль
} else if (value == 'settings') {
// TODO: Настройки
} else if (value == 'logout') {
_logout();
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'profile',
child: Row(
children: [
Icon(Icons.person),
SizedBox(width: 8),
Text('Профиль'),
],
),
),
const PopupMenuItem(
value: 'settings',
child: Row(
children: [
Icon(Icons.settings),
SizedBox(width: 8),
Text('Настройки'),
],
),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'logout',
child: Row(
children: [
Icon(Icons.logout, color: Colors.red),
SizedBox(width: 8),
Text('Выйти', style: TextStyle(color: Colors.red)),
],
),
),
],
),
],
),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
onPressed: () {
// TODO: Создать новый чат
},
child: const Icon(Icons.edit),
),
);
}
Widget _buildBody() {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage.isNotEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text('Ошибка загрузки', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_errorMessage,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
setState(() {
_loading = true;
_errorMessage = '';
});
_loadUser();
},
icon: const Icon(Icons.refresh),
label: const Text('Повторить'),
),
],
),
);
}
return _buildChatsList();
}
Widget _buildChatsList() {
return Column(
children: [
// Приветствие пользователя
if (_currentUser != null)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
child: Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
_currentUser!.firstName.isNotEmpty
? _currentUser!.firstName[0].toUpperCase()
: '?',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Привет, ${_currentUser!.firstName}!',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
'@${_currentUser!.username}',
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
),
],
),
),
],
),
),
// Список чатов (пока пустой)
Expanded(
child: ListView.builder(
itemCount: 0, // TODO: Заменить на реальные чаты
itemBuilder: (context, index) {
// TODO: Реализовать отображение чатов
return const SizedBox.shrink();
},
),
),
// Заглушка для пустого списка
if (true) // TODO: Убрать когда будут чаты
const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.chat_bubble_outline, size: 80, color: Colors.grey),
SizedBox(height: 16),
Text(
'Пока нет чатов',
style: TextStyle(
fontSize: 18,
color: Colors.grey,
),
),
SizedBox(height: 8),
Text(
'Нажмите + чтобы начать общение',
style: TextStyle(
fontSize: 14,
color: Colors.grey,
),
),
],
),
),
],
);
}
}
### ФАЙЛ: lib/screens/chat_screen.dart
--------------------------------------------------------------------------------
import 'dart:async';
import 'package:flutter/material.dart';
import '../services/api_service.dart';
import '../services/auth_service.dart';
import '../models/chat.dart';
import '../models/message.dart';
import '../widgets/message_bubble.dart';
class ChatScreen extends StatefulWidget {
final Chat chat;
const ChatScreen({super.key, required this.chat});
@override
State createState() => _ChatScreenState();
}
class _ChatScreenState extends State {
final _controller = TextEditingController();
final _scrollController = ScrollController();
final _focusNode = FocusNode();
List _messages = [];
bool _loading = true;
bool _sending = false;
String? _myUserId;
Timer? _pollTimer;
Message? _replyTo;
@override
void initState() {
super.initState();
_init();
}
Future _init() async {
_myUserId = await AuthService.getUserId();
await _loadMessages();
_pollTimer = Timer.periodic(const Duration(seconds: 2), (_) {
if (mounted) _loadMessages(silent: true);
});
}
Future _loadMessages({bool silent = false}) async {
try {
if (!silent) setState(() => _loading = true);
final messages = await ApiService.getMessages(widget.chat.id);
if (mounted) {
final oldCount = _messages.length;
setState(() {
_messages = messages;
_loading = false;
});
if (messages.length > oldCount) {
_scrollToBottom();
}
for (final msg in messages) {
if (msg.senderId != _myUserId && msg.id > 0) {
ApiService.markAsRead(msg.id);
}
}
}
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
void _scrollToBottom() {
if (_scrollController.hasClients) {
Future.delayed(const Duration(milliseconds: 100), () {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
});
}
}
Future _send() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
_controller.clear();
setState(() {
_sending = true;
_replyTo = null;
});
try {
final message = await ApiService.sendMessage(
chatId: widget.chat.id,
content: text,
replyTo: _replyTo?.id.toString(),
);
if (message != null) {
await _loadMessages(silent: true);
_scrollToBottom();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка отправки: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) setState(() => _sending = false);
}
}
void _setReply(Message message) {
setState(() => _replyTo = message);
_focusNode.requestFocus();
}
void _showMessageOptions(Message message) {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.reply),
title: const Text('Ответить'),
onTap: () {
Navigator.pop(ctx);
_setReply(message);
},
),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Копировать'),
onTap: () {
Navigator.pop(ctx);
},
),
if (message.senderId == _myUserId) ...[
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Редактировать'),
onTap: () {
Navigator.pop(ctx);
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('Удалить', style: TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(ctx);
},
),
],
],
),
),
);
}
@override
void dispose() {
_pollTimer?.cancel();
_controller.dispose();
_scrollController.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: Theme.of(context).primaryColor,
backgroundImage: widget.chat.companion?.avatarUrl != null
? NetworkImage(widget.chat.companion!.avatarUrl!)
: null,
child: widget.chat.companion?.avatarUrl == null
? Text(
widget.chat.initial,
style: const TextStyle(color: Colors.white, fontSize: 14),
)
: null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.chat.displayName,
style: const TextStyle(fontSize: 16),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
widget.chat.isCompanionOnline ? 'в сети' : 'был(а) недавно',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
),
),
],
),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.call),
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.videocam),
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {},
),
],
),
body: Column(
children: [
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _messages.isEmpty
? const Center(
child: Text(
'Начните общение!',
style: TextStyle(color: Colors.grey),
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _messages.length,
itemBuilder: (context, index) {
final msg = _messages[index];
final isMe = msg.senderId == _myUserId;
final showName = widget.chat.type != ChatType.private &&
(index == 0 ||
_messages[index - 1].senderId != msg.senderId);
return MessageBubble(
message: msg,
isMe: isMe,
showSenderName: showName,
onLongPress: () => _showMessageOptions(msg),
);
},
),
),
if (_replyTo != null)
Container(
padding: const EdgeInsets.all(8),
color: Theme.of(context).colorScheme.surfaceVariant,
child: Row(
children: [
Icon(Icons.reply, color: Theme.of(context).primaryColor),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_replyTo!.sender?.displayName ?? 'Пользователь',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).primaryColor,
),
),
Text(
_replyTo!.content,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13),
),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => setState(() => _replyTo = null),
),
],
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.attach_file),
onPressed: () {},
),
Expanded(
child: TextField(
controller: _controller,
focusNode: _focusNode,
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
decoration: InputDecoration(
hintText: 'Сообщение...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
),
),
),
const SizedBox(width: 8),
IconButton(
onPressed: _sending ? null : _send,
icon: _sending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send_rounded),
color: Theme.of(context).primaryColor,
),
],
),
),
),
],
),
);
}
}
### ФАЙЛ: lib/screens/verify_screen.dart
--------------------------------------------------------------------------------
import 'package:flutter/material.dart';
import '../services/api_service.dart';
class VerifyScreen extends StatefulWidget {
final String email;
final String type; // 'registration' или 'login'
const VerifyScreen({
super.key,
required this.email,
required this.type,
});
@override
State createState() => _VerifyScreenState();
}
class _VerifyScreenState extends State {
final _codeController = TextEditingController();
bool _isLoading = false;
int _resendTimer = 0;
@override
void initState() {
super.initState();
_startResendTimer();
}
void _startResendTimer() {
setState(() => _resendTimer = 60);
_tickTimer();
}
void _tickTimer() {
Future.delayed(const Duration(seconds: 1), () {
if (!mounted) return;
if (_resendTimer > 0) {
setState(() => _resendTimer--);
_tickTimer();
}
});
}
Future _verify() async {
final code = _codeController.text.trim();
if (code.length != 6) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Введите 6-значный код')),
);
return;
}
setState(() => _isLoading = true);
try {
final response = await ApiService.verifyEmail(
email: widget.email,
code: code,
);
if (response['success'] == true) {
if (mounted) {
Navigator.pushNamedAndRemoveUntil(context, '/home', (route) => false);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response['message'] ?? response['error'] ?? 'Ошибка верификации'),
backgroundColor: Colors.red,
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка сети: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future _resendCode() async {
if (_resendTimer > 0) return;
setState(() => _isLoading = true);
try {
final response = await ApiService.resendCode(
email: widget.email,
type: widget.type,
);
if (response['success'] == true) {
_startResendTimer();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Код отправлен повторно')),
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response['error'] ?? 'Ошибка'),
backgroundColor: Colors.red,
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка сети: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
void dispose() {
_codeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Подтверждение email'),
),
body: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 40),
const Icon(Icons.email, size: 80, color: Colors.blue),
const SizedBox(height: 20),
Text(
'Введите код из письма',
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
'Код отправлен на ${widget.email}',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
TextField(
controller: _codeController,
decoration: const InputDecoration(
labelText: 'Код подтверждения',
border: OutlineInputBorder(),
hintText: '123456',
),
keyboardType: TextInputType.number,
maxLength: 6,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 24,
letterSpacing: 8,
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _verify,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const CircularProgressIndicator()
: const Text('Подтвердить', style: TextStyle(fontSize: 16)),
),
const SizedBox(height: 16),
TextButton(
onPressed: _resendTimer > 0 || _isLoading ? null : _resendCode,
child: _resendTimer > 0
? Text('Отправить код повторно через $_resendTimer сек')
: const Text('Отправить код повторно'),
),
],
),
),
);
}
}
### ФАЙЛ: lib/screens/login_screen.dart
--------------------------------------------------------------------------------
import 'package:flutter/material.dart';
import '../services/api_service.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State createState() => _LoginScreenState();
}
class _LoginScreenState extends State {
final _formKey = GlobalKey();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
bool _useCodeLogin = false;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future _login() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
if (_useCodeLogin) {
// Вход по коду
final response = await ApiService.loginWithCodeRequest(
identifier: _emailController.text.trim(),
);
if (response['success'] == true) {
if (mounted) {
Navigator.pushNamed(
context,
'/verify',
arguments: {
'email': response['email']?.toString() ?? _emailController.text.trim(),
'type': 'login',
},
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response['error'] ?? 'Ошибка'),
backgroundColor: Colors.red,
),
);
}
}
} else {
// Вход по паролю
final response = await ApiService.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
if (response['success'] == true) {
if (mounted) {
Navigator.pushNamedAndRemoveUntil(context, '/home', (route) => false);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response['error'] ?? 'Ошибка входа'),
backgroundColor: Colors.red,
),
);
}
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка сети: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Вход'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 40),
const Icon(Icons.chat_bubble, size: 80, color: Colors.green),
const SizedBox(height: 20),
Text(
'Плюс Чат',
style: Theme.of(context).textTheme.headlineLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email или Username',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Введите email или username';
}
return null;
},
),
if (!_useCodeLogin) ...[
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Пароль',
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Введите пароль';
}
return null;
},
),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _login,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const CircularProgressIndicator()
: const Text('Войти', style: TextStyle(fontSize: 16)),
),
const SizedBox(height: 16),
TextButton(
onPressed: () {
setState(() {
_useCodeLogin = !_useCodeLogin;
_passwordController.clear();
});
},
child: Text(_useCodeLogin
? 'Войти по паролю'
: 'Войти по коду из почты'),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => Navigator.pushNamed(context, '/register'),
child: const Text('Нет аккаунта? Зарегистрироваться'),
),
],
),
),
),
);
}
}
### ФАЙЛ: lib/screens/register_screen.dart
--------------------------------------------------------------------------------
import 'package:flutter/material.dart';
import '../services/api_service.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State {
final _formKey = GlobalKey();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _middleNameController = TextEditingController();
final _usernameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_middleNameController.dispose();
_usernameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future _register() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final response = await ApiService.register(
firstName: _firstNameController.text.trim(),
lastName: _lastNameController.text.trim(),
middleName: _middleNameController.text.trim().isEmpty
? null
: _middleNameController.text.trim(),
username: _usernameController.text.trim(),
email: _emailController.text.trim().toLowerCase(),
password: _passwordController.text,
);
if (response['success'] == true) {
if (mounted) {
Navigator.pushNamed(
context,
'/verify',
arguments: {
'email': _emailController.text.trim().toLowerCase(),
'type': 'registration',
},
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response['error'] ?? 'Ошибка регистрации'),
backgroundColor: Colors.red,
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка сети: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Регистрация'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 20),
TextFormField(
controller: _firstNameController,
decoration: const InputDecoration(
labelText: 'Имя *',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().length < 2) {
return 'Минимум 2 символа';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _lastNameController,
decoration: const InputDecoration(
labelText: 'Фамилия *',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().length < 2) {
return 'Минимум 2 символа';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _middleNameController,
decoration: const InputDecoration(
labelText: 'Отчество (необязательно)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username *',
prefixText: '@',
border: OutlineInputBorder(),
helperText: '3-30 символов: буквы, цифры, _',
),
validator: (value) {
if (value == null || !RegExp(r'^[a-zA-Z0-9_]{3,30}$').hasMatch(value)) {
return 'Неверный формат';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email *',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || !value.contains('@')) {
return 'Неверный email';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Пароль *',
border: OutlineInputBorder(),
helperText: 'Минимум 8 символов',
),
obscureText: true,
validator: (value) {
if (value == null || value.length < 8) {
return 'Минимум 8 символов';
}
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _register,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const CircularProgressIndicator()
: const Text('Зарегистрироваться', style: TextStyle(fontSize: 16)),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => Navigator.pushNamed(context, '/login'),
child: const Text('Уже есть аккаунт? Войти'),
),
],
),
),
),
);
}
}
### ФАЙЛ: lib/services/auth_service.dart
--------------------------------------------------------------------------------
import 'package:shared_preferences/shared_preferences.dart';
class AuthService {
static const String _keyAccessToken = 'access_token';
static const String _keyRefreshToken = 'refresh_token';
static const String _keyUserId = 'user_id';
static const String _keyEmail = 'email';
static const String _keyUsername = 'username';
static const String _keyFirstName = 'first_name';
static const String _keyLastName = 'last_name';
// ============================================
// СОХРАНЕНИЕ
// ============================================
static Future saveTokens(String accessToken, String refreshToken) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyAccessToken, accessToken);
await prefs.setString(_keyRefreshToken, refreshToken);
}
static Future saveUserId(String userId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyUserId, userId);
}
static Future saveUserInfo({
required String email,
required String username,
required String firstName,
required String lastName,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyEmail, email);
await prefs.setString(_keyUsername, username);
await prefs.setString(_keyFirstName, firstName);
await prefs.setString(_keyLastName, lastName);
}
// ============================================
// ПОЛУЧЕНИЕ
// ============================================
static Future getToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyAccessToken);
}
static Future getRefreshToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyRefreshToken);
}
static Future getUserId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyUserId);
}
static Future getEmail() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyEmail);
}
static Future getUsername() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyUsername);
}
static Future getFirstName() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyFirstName);
}
static Future getLastName() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLastName);
}
// ============================================
// ПРОВЕРКИ
// ============================================
static Future isLoggedIn() async {
final token = await getToken();
return token != null && token.isNotEmpty;
}
// ============================================
// ВЫХОД
// ============================================
static Future logout() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyAccessToken);
await prefs.remove(_keyRefreshToken);
await prefs.remove(_keyUserId);
await prefs.remove(_keyEmail);
await prefs.remove(_keyUsername);
await prefs.remove(_keyFirstName);
await prefs.remove(_keyLastName);
}
}
### ФАЙЛ: lib/services/api_service.dart
--------------------------------------------------------------------------------
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'auth_service.dart';
import '../models/user.dart';
import '../models/chat.dart';
import '../models/message.dart';
class ApiService {
static const String baseUrl = 'https://xn--80avljg2a1c.xn--p1ai';
// ============================================
// БАЗОВЫЙ МЕТОД
// ============================================
static Future