04-06-26+16-27

This commit is contained in:
Artur 2026-06-04 16:27:28 +05:00
parent 11340bdca1
commit e9b025a34d
15 changed files with 1403 additions and 1089 deletions

View File

@ -231,11 +231,11 @@ class AuthProvider extends ChangeNotifier {
return true;
}
Future<bool> updateProfileAndSecurity({
required String firstName,
Future<bool> setupAccount(
String firstName,
String? lastName,
required String masterPassword,
}) async {
String masterPassword,
) async {
notifyListeners();
try {

View File

@ -1,7 +1,10 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../logic/auth_provider.dart';
import 'contacts_screen.dart';
import 'package:chepuhagram/logic/auth_provider.dart';
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
import 'package:chepuhagram/core/theme_manager.dart';
import 'dart:io';
class AccountSetupScreen extends StatefulWidget {
const AccountSetupScreen({super.key});
@ -10,225 +13,276 @@ class AccountSetupScreen extends StatefulWidget {
State<AccountSetupScreen> createState() => _AccountSetupScreenState();
}
class _AccountSetupScreenState extends State<AccountSetupScreen> {
class _AccountSetupScreenState extends State<AccountSetupScreen>
with SingleTickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _masterPasswordController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_masterPasswordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
Future<void> _setupAccount() async {
if (!_formKey.currentState!.validate()) return;
_fadeAnimation =
Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeIn,
));
setState(() {
_isLoading = true;
_errorMessage = null;
});
_slideAnimation =
Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.fastOutSlowIn,
));
try {
final authProvider = context.read<AuthProvider>();
// Отправляем данные на сервер с мастер-паролем
final success = await authProvider.updateProfileAndSecurity(
firstName: _firstNameController.text.trim(),
lastName: _lastNameController.text.trim(),
masterPassword: _masterPasswordController.text,
);
if (success && mounted) {
// Переходим на экран контактов
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
} else if (mounted) {
setState(() {
_errorMessage = 'Ошибка при сохранении профиля. Попробуйте еще раз.';
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Ошибка: ${e.toString()}';
_isLoading = false;
});
}
}
_animationController.forward();
}
@override
Widget build(BuildContext context) {
final authProvider = context.watch<AuthProvider>();
final themeProv = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Завершение настройки'),
centerTitle: true,
elevation: 0,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 16),
Text(
'Завершите настройку вашего профиля',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Введите ваше имя, фамилию и создайте мастер-пароль. Мастер-пароль будет использоваться для защиты ваших ключей шифрования.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
const SizedBox(height: 32),
// Поле Имя
TextFormField(
controller: _firstNameController,
decoration: InputDecoration(
labelText: 'Имя *',
prefixIcon: const Icon(Icons.person_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
hintText: 'Введите ваше имя',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Имя не может быть пустым';
}
return null;
},
),
const SizedBox(height: 16),
// Поле Фамилия
TextFormField(
controller: _lastNameController,
decoration: InputDecoration(
labelText: 'Фамилия',
prefixIcon: const Icon(Icons.person_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
hintText: 'Введите вашу фамилию (опционально)',
body: Stack(
children: [
// Background Wallpaper
if (themeProv.wallpaperPath != null)
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: FileImage(File(themeProv.wallpaperPath!)),
fit: BoxFit.cover,
),
),
const SizedBox(height: 16),
// Поле Мастер-пароль
TextFormField(
controller: _masterPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Мастер-пароль *',
prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
hintText: 'Создайте надежный пароль',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Мастер-пароль не может быть пустым';
}
if (value.length < 8) {
return 'Пароль должен содержать минимум 8 символов';
}
return null;
},
),
// Blur Overlay
if (themeProv.wallpaperPath != null)
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: Container(
color: Colors.black.withOpacity(0.1),
),
const SizedBox(height: 16),
),
// Поле Подтверждение пароля
TextFormField(
controller: _confirmPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Подтвердите пароль *',
prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
hintText: 'Повторите пароль',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Подтвердите пароль';
}
if (value != _masterPasswordController.text) {
return 'Пароли не совпадают';
}
return null;
},
),
const SizedBox(height: 24),
// Сообщение об ошибке
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
),
const SizedBox(height: 24),
// Кнопка подтверждения
ElevatedButton(
onPressed: _isLoading ? null : _setupAccount,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text(
'Завершить настройку',
style: TextStyle(fontSize: 16),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: Column(
children: [
Icon(
Icons.person_add_alt_1_outlined,
size: 80,
color: colorScheme.primary,
),
const SizedBox(height: 16),
Text(
"Настройка аккаунта",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
const SizedBox(height: 12),
Text(
"Укажите ваше имя и создайте мастер-пароль",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: colorScheme.outline,
),
),
],
),
),
),
),
const SizedBox(height: 48),
const SizedBox(height: 24),
Text(
'Сохраните мастер-пароль в надежном месте. Он потребуется для восстановления ключей шифрования при переустановке приложения.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
// Поле Имя
_buildTextField(
controller: _firstNameController,
label: "Имя",
icon: Icons.person_outline,
validator: (value) =>
value!.isEmpty ? "Введите ваше имя" : null,
),
const SizedBox(height: 16),
// Поле Фамилия
_buildTextField(
controller: _lastNameController,
label: "Фамилия",
icon: Icons.person_outline,
),
const SizedBox(height: 24),
// Поле Мастер-пароль
_buildTextField(
controller: _passwordController,
label: "Мастер-пароль",
icon: Icons.lock_outline,
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Введите мастер-пароль';
}
if (value.length < 8) {
return 'Пароль должен быть не менее 8 символов';
}
return null;
},
),
const SizedBox(height: 16),
// Поле Подтверждение пароля
_buildTextField(
controller: _confirmPasswordController,
label: "Подтвердите пароль",
icon: Icons.lock_person_outlined,
obscureText: true,
validator: (value) {
if (value != _passwordController.text) {
return 'Пароли не совпадают';
}
return null;
},
),
const SizedBox(height: 40),
// Кнопка Продолжить
ElevatedButton(
onPressed: authProvider.isLoading ? null : _submit,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
child: authProvider.isLoading
? const SizedBox(
height: 20,
width: 20,
child:
CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text("Создать аккаунт",
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
),
],
),
),
],
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String label,
required IconData icon,
String? Function(String?)? validator,
bool obscureText = false,
}) {
final colorScheme = Theme.of(context).colorScheme;
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.35),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outlineVariant.withOpacity(0.15),
width: 1,
),
),
child: TextFormField(
controller: controller,
validator: validator,
obscureText: obscureText,
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(color: colorScheme.outline),
prefixIcon: Icon(icon, color: colorScheme.outline),
border: InputBorder.none,
contentPadding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
),
),
),
),
);
}
}
void _submit() async {
FocusScope.of(context).unfocus();
if (!_formKey.currentState!.validate()) return;
final authProvider = context.read<AuthProvider>();
try {
await authProvider.setupAccount(
_firstNameController.text.trim(),
_lastNameController.text.trim(),
_passwordController.text,
);
if (mounted) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => const ContactsScreen()),
(route) => false,
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString().replaceAll('Exception: ', '')),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
}
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_animationController.dispose();
super.dispose();
}
}

View File

@ -577,13 +577,11 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
: const SizedBox.shrink(),
title: Consumer<ContactProvider>(
builder: (context, contactProvider, child) {
// Реактивно отслеживаем изменения пользователя в провайдере (например, аватарку)
final freshContact = contactProvider.contacts.firstWhere(
(c) => c.id == widget.contact.id,
orElse: () => widget.contact,
);
// ФИКС ОНЛАЙНА: Определяем статус на основе встроенного таймера экрана чата
final bool currentOnline = freshContact.isOnline || _isOnline;
final String subtitleText = currentOnline
? 'в сети'
@ -605,6 +603,18 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
lName = '';
}
final String localFullName = '${_currentContact.name} ${_currentContact.surname}'
.trim();
final contactInitials = localFullName.isNotEmpty
? localFullName
.trim()
.split(RegExp(r'\s+'))
.take(2)
.map((e) => e[0].toUpperCase())
.join()
: '?';
final String cleanFullName = '$fName $lName'.trim();
return InkWell(
onTap: () {
@ -635,9 +645,7 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.4),
color: Theme.of(context).colorScheme.primaryContainer,
image: freshContact.avatarUrl != null
? DecorationImage(
image: NetworkImage(freshContact.avatarUrl!),
@ -647,14 +655,17 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
),
child: freshContact.avatarUrl == null
? Center(
child: Text(
_currentContact.name.isNotEmpty
? _currentContact.name[0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
child: Padding(
padding: const EdgeInsets.only(bottom: 1),
child: Text(
contactInitials,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
),
),
)

View File

@ -337,7 +337,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
final localName = _localFullNames[contact.id];
final displayName = (localName != null && localName.isNotEmpty)
? localName
: contact.name;
: '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim();
final contactInitials = displayName.isNotEmpty
? displayName
@ -402,7 +402,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primary.withOpacity(0.08),
color: colorScheme.primaryContainer,
),
child: ClipOval(
child: Stack(
@ -411,9 +411,9 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
Text(
contactInitials,
style: TextStyle(
color: colorScheme.primary,
fontSize: 20,
fontWeight: FontWeight.bold,
fontSize: 18,
color: colorScheme.onPrimaryContainer,
),
),
if (contact.avatarUrl != null)
@ -1020,7 +1020,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
if (didPop) return;
if (_selectedContact != null && isPhoneFormFactor) {
_clearSelectedContact(); // Плавно закрываем чат и возвращаемся к списку
_clearSelectedContact();
}
},
child: _buildResponsiveBody(isPhoneFormFactor),

View File

@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '/core/constants.dart';
import 'dart:ui';
import 'dart:io';
import '/data/models/message_model.dart';
import '/data/models/contact_model.dart';
import '/logic/contact_provider.dart';
import '/domain/services/api_service.dart';
import '/core/theme_manager.dart';
class ForwardContactPickerScreen extends StatefulWidget {
final MessageModel message;
@ -57,13 +59,18 @@ class _ForwardContactPickerScreenState
}
String _getDisplayName(Contact contact) {
if (_prefs == null) return contact.name;
if (_prefs == null) return '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim();
final id = contact.id;
final savedName = _prefs!.getString('firstname_$id');
final savedSurname = _prefs!.getString('lastname_$id');
String? displayName;
if (savedName != null && savedName.isNotEmpty) {
return savedName;
displayName = savedName;
}
return contact.name;
if (savedSurname != null && savedSurname.isNotEmpty) {
(displayName == null || displayName.isEmpty) ? displayName = savedSurname : displayName += " $savedSurname";
}
return displayName ?? '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim();
}
String _formatTime(DateTime? time) {
@ -74,290 +81,254 @@ class _ForwardContactPickerScreenState
return '$hour:$minute';
}
String _getInitials(String name) {
if (name.isEmpty) return '?';
final names = name.trim().split(RegExp(r'\s+')).where((s) => s.isNotEmpty).toList();
if (names.length > 1) {
return (names[0][0] + names[1][0]).toUpperCase();
} else if (names.isNotEmpty) {
return names[0][0].toUpperCase();
}
return '?';
}
@override
Widget build(BuildContext context) {
final contactProvider = context.watch<ContactProvider>();
final contacts = contactProvider.contacts;
final isLoading = _isInitLoading || contactProvider.isLoading;
final primaryColor = Theme.of(context).colorScheme.primary;
final themeProv = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: colorScheme.surface.withOpacity(0.85),
elevation: 0,
scrolledUnderElevation: 0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(bottom: Radius.circular(24)),
),
flexibleSpace: ClipRRect(
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(24)),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(color: Colors.transparent),
),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.of(context).pop(),
),
title: const Text(
'Переслать...',
style: TextStyle(fontWeight: FontWeight.w600),
style: TextStyle(fontWeight: FontWeight.bold),
),
actions: [
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _selectedContact != null ? 1.0 : 0.4,
child: TextButton(
onPressed: _selectedContact != null
? () => Navigator.of(context).pop(_selectedContact)
: null,
child: const Text(
'Продолжить',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
opacity: _selectedContact != null ? 1.0 : 0.5,
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ElevatedButton(
onPressed: _selectedContact != null
? () => Navigator.of(context).pop(_selectedContact)
: null,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 20),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
child: const Text('Далее'),
),
),
),
const SizedBox(width: 8),
],
),
body: () {
if (isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (contactProvider.error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
'Ошибка: ${contactProvider.error}',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
),
);
}
if (contacts.isEmpty) {
return const Center(
child: Text(
'Нет активных чатов для пересылки.',
style: TextStyle(color: Colors.grey, fontSize: 15),
),
);
}
return ListView.builder(
itemCount: contacts.length,
itemBuilder: (context, index) {
final contact = contacts[index];
final isSelected = _selectedContact?.id == contact.id;
// Логика формирования текста сообщения (1-в-1 как в твоем ContactTile)
final bool isDecrypted = contact.isLastMsgDecrypted ?? false;
final String subtitleText = isDecrypted
? (contact.lastMessage == null
? "Нет сообщений"
: "${contact.lastMessageType != null ? MessageModel.getMediaPreview(contact.lastMessageType!) : ''} ${contact.lastMessage}"
.trim())
: (contact.lastMessage != null
? "Ожидание дешифровки..."
: "Нет сообщений");
// Логика формирования URL аватарки
final avatarUrl = contact.effectiveAvatarUrl;
final bool hasAvatar = avatarUrl != null && avatarUrl.isNotEmpty;
return InkWell(
onTap: () {
setState(() {
if (isSelected) {
_selectedContact = null;
} else {
_selectedContact = contact;
}
});
},
child: Container(
color: isSelected
? primaryColor.withOpacity(0.08)
: Colors.transparent,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
// 1. АВАТАРКА
leading: Stack(
children: [
if (hasAvatar)
CircleAvatar(
radius: 24,
backgroundColor: Colors.grey[200],
child: ClipOval(
child: ClipOval(
child: Image.network(
avatarUrl, // Первым аргументом идет строка, без "imageUrl:"
width: 48,
height: 48,
fit: BoxFit.cover,
headers: token != null
? {'Authorization': 'Bearer $token'}
: null, // Заменено на headers
// Аналог placeholder
loadingBuilder:
(context, child, loadingProgress) {
if (loadingProgress == null) return child;
return const SizedBox(
width: 48,
height: 48,
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
);
},
// Аналог errorWidget
errorBuilder: (context, error, stackTrace) {
return CircleAvatar(
radius: 24, // 24 радиус = 48 ширина/высота
backgroundColor: primaryColor.withOpacity(
0.1,
),
child: Text(
_getDisplayName(contact).isNotEmpty
? _getDisplayName(
contact,
)[0].toUpperCase()
: '?',
style: TextStyle(
color: primaryColor,
fontWeight: FontWeight.bold,
),
),
);
},
),
),
),
)
else
CircleAvatar(
radius: 24,
backgroundColor: primaryColor.withOpacity(0.1),
child: Text(
_getDisplayName(contact).isNotEmpty
? _getDisplayName(contact)[0].toUpperCase()
: '?',
style: TextStyle(
color: primaryColor,
fontWeight: FontWeight.bold,
),
),
),
if (contact.isOnline == true)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(
context,
).scaffoldBackgroundColor,
width: 2,
),
),
),
),
],
),
// 2. ИМЯ
title: Text(
_getDisplayName(contact),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
// 3. ПОСЛЕДНЕЕ СООБЩЕНИЕ
subtitle: Text(
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey),
),
// 4. ПРАВАЯ ЧАСТЬ (Анимация переключения Время <-> Галочка)
trailing: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder:
(Widget child, Animation<double> animation) {
return ScaleTransition(
scale: animation,
child: child,
);
},
child: isSelected
? Container(
key: const ValueKey('checkmark'),
width: 24,
height: 24,
decoration: BoxDecoration(
color: primaryColor,
shape: BoxShape.circle,
),
child: const Icon(
Icons.check_rounded,
color: Colors.white,
size: 16,
),
)
: Column(
key: const ValueKey('time_and_badge'),
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(contact.lastMessageTime),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
if (contact.unreadCount > 0) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: primaryColor.withAlpha(
(0.5 * 255).round(),
),
shape: BoxShape.circle,
),
child: Text(
'${contact.unreadCount}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
],
],
),
),
body: Stack(
children: [
if (themeProv.wallpaperPath != null)
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: FileImage(File(themeProv.wallpaperPath!)),
fit: BoxFit.cover,
),
),
);
},
);
}(),
),
SafeArea(
child: () {
if (isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (contactProvider.error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
'Ошибка: ${contactProvider.error}',
textAlign: TextAlign.center,
style: TextStyle(color: colorScheme.outline),
),
),
);
}
if (contacts.isEmpty) {
return Center(
child: Text(
'Нет активных чатов для пересылки.',
style: TextStyle(color: colorScheme.outline, fontSize: 15),
),
);
}
return ListView.builder(
padding: const EdgeInsets.only(top: 8, left: 8, right: 8),
itemCount: contacts.length,
itemBuilder: (context, index) {
final contact = contacts[index];
final isSelected = _selectedContact?.id == contact.id;
final bool isDecrypted = contact.isLastMsgDecrypted;
final String subtitleText = isDecrypted
? (contact.lastMessage == null
? "Нет сообщений"
: "${contact.lastMessageType != null ? MessageModel.getMediaPreview(contact.lastMessageType!) : ''} ${contact.lastMessage}".trim())
: (contact.lastMessage != null
? "Ожидание дешифровки..."
: "Нет сообщений");
final avatarUrl = contact.effectiveAvatarUrl;
final bool hasAvatar = avatarUrl != null && avatarUrl.isNotEmpty;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary.withOpacity(0.2) : colorScheme.surfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected ? colorScheme.primary : colorScheme.outlineVariant.withOpacity(0.2),
width: isSelected ? 2 : 1,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
setState(() {
_selectedContact = isSelected ? null : contact;
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
// Avatar
CircleAvatar(
radius: 26,
backgroundColor: colorScheme.primaryContainer,
child: hasAvatar
? ClipOval(
child: Image.network(
avatarUrl,
fit: BoxFit.cover,
width: 52,
height: 52,
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: Text(
_getInitials(_getDisplayName(contact)),
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: colorScheme.onPrimaryContainer),
),
);
},
errorBuilder: (context, error, stackTrace) {
return Center(
child: Text(
_getInitials(_getDisplayName(contact)),
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: colorScheme.onPrimaryContainer),
),
);
},
),
)
: Center(
child: Text(
_getInitials(_getDisplayName(contact)),
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: colorScheme.onPrimaryContainer),
),
),
),
const SizedBox(width: 12),
// Name and Message
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getDisplayName(contact),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 2),
Text(
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.outline),
),
],
),
),
const SizedBox(width: 12),
// Checkmark
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) => ScaleTransition(scale: animation, child: child),
child: isSelected
? Container(
key: const ValueKey('checkmark'),
width: 28,
height: 28,
decoration: BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle),
child: const Icon(Icons.check_rounded, color: Colors.white, size: 18),
)
:
Text(
_formatTime(contact.lastMessageTime),
key: ValueKey(_formatTime(contact.lastMessageTime)),
textAlign: TextAlign.end,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.outline),
),
),
],
),
),
),
),
),
),
),
);
},
);
}(),
),
],
),
);
}
}

View File

@ -1,13 +1,16 @@
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../logic/auth_provider.dart';
import '../../domain/services/crypto_service.dart';
import '../../domain/services/api_service.dart';
import 'account_setup_screen.dart';
import 'package:chepuhagram/logic/auth_provider.dart';
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:chepuhagram/presentation/screens/account_setup_screen.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../core/constants.dart';
import 'package:chepuhagram/core/constants.dart';
import 'package:chepuhagram/core/theme_manager.dart';
import 'dart:io';
class KeyRecoveryScreen extends StatefulWidget {
const KeyRecoveryScreen({super.key});
@ -16,12 +19,40 @@ class KeyRecoveryScreen extends StatefulWidget {
State<KeyRecoveryScreen> createState() => _KeyRecoveryScreenState();
}
class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> with SingleTickerProviderStateMixin {
bool _isLoading = false;
String? _errorMessage;
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_fadeAnimation =
Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeIn,
));
_slideAnimation =
Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.fastOutSlowIn,
));
_animationController.forward();
}
Future<void> _startFresh() async {
setState(() {
_isLoading = true;
@ -31,19 +62,15 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
try {
final authProvider = context.read<AuthProvider>();
// Удаляем все сообщения пользователя
try {
final api = ApiService();
await api.deleteAllMessages();
} catch (e) {
print('Ошибка при удалении сообщений: $e');
// Продолжаем даже если удаление сообщений не удалось
}
// Удаляем старые ключи и создаем новые
await authProvider.resetKeys();
// Переходим на экран настройки для создания новых ключей
if (mounted) {
Navigator.pushReplacement(
context,
@ -62,6 +89,7 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
Future<void> _recoverKeys() async {
if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus();
setState(() {
_isLoading = true;
@ -73,11 +101,9 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
final apiService = ApiService();
final cryptoService = CryptoService();
// Получаем токен
final token = await apiService.getAccessToken();
if (token == null) throw Exception('Не авторизован');
// Скачиваем зашифрованный приватный ключ с сервера
final response = await http.get(
Uri.parse('${AppConstants.baseUrl}/users/me'),
headers: {'Authorization': 'Bearer $token'},
@ -94,20 +120,16 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
throw Exception('Зашифрованный ключ не найден на сервере');
}
// Расшифровываем приватный ключ
final decryptedPrivateKey = await cryptoService.decryptPrivateKey(
encryptedPrivateKey,
_passwordController.text,
);
// Сохраняем расшифрованный ключ локально
await cryptoService.savePrivateKey(decryptedPrivateKey);
// Обновляем статус в AuthProvider
await authProvider.tryAutoLogin();
if (mounted) {
// Возвращаемся на главный экран
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const ContactsScreen()),
@ -125,177 +147,265 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Восстановление ключей'),
centerTitle: true,
elevation: 0,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
Icon(
Icons.security_outlined,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'Восстановление ключей шифрования',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
Text(
'Вы переустановили приложение или используете новый девайс. У вас есть два варианта:',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 32),
final themeProv = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
// Вариант 1: Начать с чистого листа
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.restart_alt_outlined,
color: Theme.of(context).colorScheme.primary,
size: 28,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Начать с чистого листа',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
return Scaffold(
body: Stack(
children: [
if (themeProv.wallpaperPath != null)
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: FileImage(File(themeProv.wallpaperPath!)),
fit: BoxFit.cover,
),
),
),
if (themeProv.wallpaperPath != null)
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: Container(
color: Colors.black.withOpacity(0.1),
),
),
SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: Column(
children: [
Icon(
Icons.security_outlined,
size: 80,
color: colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'Восстановление ключей',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
),
],
const SizedBox(height: 16),
Text(
'Вы переустановили приложение или вошли на новом устройстве. Выберите один из вариантов:',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: colorScheme.outline),
),
],
),
),
const SizedBox(height: 12),
Text(
'Создаются новые ключи шифрования. Старые сообщения не будут расшифрованы.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 32),
// Вариант 1: Начать с чистого листа
_buildOptionCard(
icon: Icons.restart_alt_outlined,
title: 'Начать с чистого листа',
description: 'Создаются новые ключи шифрования. Старые сообщения не будут расшифрованы.',
buttonText: 'Продолжить',
onPressed: _startFresh,
),
const SizedBox(height: 24),
// Вариант 2: Восстановить из облака
_buildRecoveryCard(),
const SizedBox(height: 24),
// Сообщение об ошибке
if (_errorMessage != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_errorMessage!,
style: TextStyle(color: colorScheme.onErrorContainer, fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _startFresh,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Продолжить'),
],
),
),
),
],
),
);
}
Widget _buildOptionCard({
required IconData icon,
required String title,
required String description,
required String buttonText,
required VoidCallback onPressed,
}) {
final colorScheme = Theme.of(context).colorScheme;
return ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.35),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: colorScheme.outlineVariant.withOpacity(0.15),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: colorScheme.primary, size: 28),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 12),
Text(description, style: TextStyle(color: colorScheme.outline, fontSize: 14)),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: Text(buttonText, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
],
),
),
),
);
}
Widget _buildRecoveryCard() {
final colorScheme = Theme.of(context).colorScheme;
return ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.35),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: colorScheme.outlineVariant.withOpacity(0.15),
width: 1,
),
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.cloud_download_outlined, color: colorScheme.primary, size: 28),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Восстановить из облака',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
],
),
),
),
const SizedBox(height: 24),
// Вариант 2: Восстановить из облака
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.cloud_download_outlined,
color: Theme.of(context).colorScheme.primary,
size: 28,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Восстановить из облака',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 12),
Text(
'Введите мастер-пароль для восстановления ключей шифрования',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Мастер-пароль',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Введите мастер-пароль';
}
return null;
},
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _recoverKeys,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Восстановить'),
),
),
],
const SizedBox(height: 12),
Text(
'Введите мастер-пароль для восстановления ключей шифрования',
style: TextStyle(color: colorScheme.outline, fontSize: 14),
),
const SizedBox(height: 16),
_buildTextField(
controller: _passwordController,
label: "Мастер-пароль",
icon: Icons.lock_outline,
obscureText: true,
validator: (value) => value!.isEmpty ? "Введите мастер-пароль" : null,
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _recoverKeys,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Восстановить', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
),
],
),
const SizedBox(height: 24),
),
),
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String label,
required IconData icon,
bool obscureText = false,
String? Function(String?)? validator,
}) {
final colorScheme = Theme.of(context).colorScheme;
// Сообщение об ошибке
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
),
],
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container( // No blur here, it's inside the card
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.5),
borderRadius: BorderRadius.circular(16),
),
child: TextFormField(
controller: controller,
obscureText: obscureText,
validator: validator,
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(color: colorScheme.outline),
prefixIcon: Icon(icon, color: colorScheme.outline),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
),
),
),
);
@ -304,6 +414,7 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
@override
void dispose() {
_passwordController.dispose();
_animationController.dispose();
super.dispose();
}
}

View File

@ -1,18 +1,21 @@
import 'dart:ui';
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
import 'package:chepuhagram/presentation/screens/account_setup_screen.dart';
import 'package:chepuhagram/presentation/screens/key_recovery_screen.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../logic/auth_provider.dart';
import '/core/theme_manager.dart';
import 'dart:io'; // Import for File
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
class _LoginScreenState extends State<LoginScreen>
with SingleTickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
@ -20,124 +23,216 @@ class _LoginScreenState extends State<LoginScreen> {
bool _showTotpField = false;
String? _errorMessage;
@override
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_fadeAnimation =
Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeIn,
));
_slideAnimation =
Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.fastOutSlowIn,
));
_animationController.forward();
}
Widget build(BuildContext context) {
final authProvider = context.watch<AuthProvider>();
final themeProv = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Иконка
Icon(
Icons.messenger_outline,
size: 80,
color: Theme.of(context).colorScheme.primary,
body: Stack(
children: [
// Background Wallpaper
if (themeProv.wallpaperPath != null)
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: FileImage(File(themeProv.wallpaperPath!)),
fit: BoxFit.cover,
),
const SizedBox(height: 16),
Text(
"Чепухаграм",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 32),
),
),
// Blur Overlay
if (themeProv.wallpaperPath != null)
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: Container(
color: Colors.black.withOpacity(0.1),
),
),
// Поле Логин
TextFormField(
controller: _usernameController,
decoration: InputDecoration(
labelText: "Логин",
prefixIcon: const Icon(Icons.person_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
fillColor: Theme.of(context).colorScheme.primary,
iconColor: Theme.of(context).colorScheme.primary,
hoverColor: Theme.of(context).colorScheme.primary,
focusColor: Theme.of(context).colorScheme.primary,
),
validator: (value) => value!.isEmpty ? "Введите логин" : null,
),
const SizedBox(height: 16),
// Поле Пароль
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: "Пароль",
prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
fillColor: Theme.of(context).colorScheme.primary,
iconColor: Theme.of(context).colorScheme.primary,
hoverColor: Theme.of(context).colorScheme.primary,
focusColor: Theme.of(context).colorScheme.primary,
),
validator: (value) =>
value!.length < 6 ? "Минимум 6 символов" : null,
),
const SizedBox(height: 16),
// Поле TOTP, если требуется
if (_showTotpField)
TextFormField(
controller: _totpController,
decoration: InputDecoration(
labelText: "TOTP код",
prefixIcon: const Icon(Icons.security),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: Column(
children: [
Icon(
Icons.messenger_outline,
size: 80,
color: colorScheme.primary,
),
const SizedBox(height: 16),
Text(
"Чепухаграм",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
],
),
),
fillColor: Theme.of(context).colorScheme.primary,
iconColor: Theme.of(context).colorScheme.primary,
hoverColor: Theme.of(context).colorScheme.primary,
focusColor: Theme.of(context).colorScheme.primary,
),
validator: (value) => value!.isEmpty ? "Введите TOTP код" : null,
),
if (_showTotpField) const SizedBox(height: 16),
const SizedBox(height: 48),
// Сообщение об ошибке
if (_errorMessage != null)
Text(
_errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
if (_errorMessage != null) const SizedBox(height: 16),
const SizedBox(height: 24),
// Кнопка Входа
ElevatedButton(
onPressed: authProvider.isLoading ? null : _submit,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
// Поле Логин
_buildTextField(
controller: _usernameController,
label: "Логин",
icon: Icons.person_outline,
validator: (value) =>
value!.isEmpty ? "Введите логин" : null,
),
),
child: authProvider.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text("Войти", style: TextStyle(fontSize: 16)),
const SizedBox(height: 16),
// Поле Пароль
_buildTextField(
controller: _passwordController,
label: "Пароль",
icon: Icons.lock_outline,
obscureText: true,
validator: (value) =>
value!.length < 6 ? "Минимум 6 символов" : null,
),
const SizedBox(height: 16),
// Поле TOTP, если требуется
if (_showTotpField)
_buildTextField(
controller: _totpController,
label: "TOTP код",
icon: Icons.security,
validator: (value) =>
value!.isEmpty ? "Введите TOTP код" : null,
),
if (_showTotpField) const SizedBox(height: 16),
// Сообщение об ошибке
if (_errorMessage != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_errorMessage!,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
),
if (_errorMessage != null) const SizedBox(height: 16),
const SizedBox(height: 24),
// Кнопка Входа
ElevatedButton(
onPressed: authProvider.isLoading ? null : _submit,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
child: authProvider.isLoading
? const SizedBox(
height: 20,
width: 20,
child:
CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text("Войти",
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
],
),
),
),
],
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String label,
required IconData icon,
bool obscureText = false,
String? Function(String?)? validator,
}) {
final colorScheme = Theme.of(context).colorScheme;
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.35),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outlineVariant.withOpacity(0.15),
width: 1,
),
),
child: TextFormField(
controller: controller,
obscureText: obscureText,
validator: validator,
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(color: colorScheme.outline),
prefixIcon: Icon(icon, color: colorScheme.outline),
border: InputBorder.none,
contentPadding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
),
),
),
@ -146,9 +241,13 @@ class _LoginScreenState extends State<LoginScreen> {
}
void _submit() async {
try {
if (!_formKey.currentState!.validate()) return;
// Сначала убираем фокус с полей, чтобы клавиатура скрылась
FocusScope.of(context).unfocus();
await Future.delayed(const Duration(milliseconds: 200));
if (!_formKey.currentState!.validate()) return;
try {
final authProvider = context.read<AuthProvider>();
final success = await authProvider.login(
_usernameController.text,
@ -157,7 +256,7 @@ class _LoginScreenState extends State<LoginScreen> {
);
if (success && mounted) {
await authProvider.initRealtime();
// Определяем путь пользователя после входа
if (authProvider.needsSetup) {
// Путь А: Первичная настройка
@ -181,24 +280,23 @@ class _LoginScreenState extends State<LoginScreen> {
}
} catch (e) {
final error = e.toString().replaceAll('Exception: ', '');
if (error.contains('TOTP код требуется')) {
if (mounted) {
setState(() {
_showTotpField = true;
_errorMessage = error;
if (error.contains('TOTP код требуется')) {
_showTotpField = true;
}
});
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
}
}
}
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
_totpController.dispose();
_animationController.dispose();
super.dispose();
}
}
}

View File

@ -3,7 +3,7 @@ import 'package:provider/provider.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'dart:math';
import '/logic/auth_provider.dart';
import 'account_settings_screen.dart';
import 'settings_screen.dart';
@ -99,8 +99,8 @@ class _MyProfileScreenState extends State<MyProfileScreen> {
child: AnimatedContainer(
duration: const Duration(milliseconds: 350),
curve: Curves.fastOutSlowIn,
width: _isAvatarExpanded ? screenWidth : 130.0,
height: _isAvatarExpanded ? screenWidth : 130.0,
width: _isAvatarExpanded ? max(screenWidth, 200) : 130.0,
height: _isAvatarExpanded ? max(screenWidth, 200) : 130.0,
margin: _isAvatarExpanded
? EdgeInsets.zero
: const EdgeInsets.only(top: 16, bottom: 8),
@ -169,7 +169,7 @@ class _MyProfileScreenState extends State<MyProfileScreen> {
Expanded(
child: _buildActionButton(
icon: Icons.photo_camera_rounded,
label: 'Фото',
label: 'Поставить фото',
onTap: _pickAvatar,
),
),

View File

@ -1,13 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../logic/auth_provider.dart';
import '../../logic/contact_provider.dart';
import 'login_screen.dart';
import 'contacts_screen.dart';
import 'account_setup_screen.dart';
import 'key_recovery_screen.dart';
import 'chat_screen.dart';
import 'package:chepuhagram/logic/auth_provider.dart';
import 'package:chepuhagram/logic/contact_provider.dart';
import 'package:chepuhagram/presentation/screens/login_screen.dart';
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
import 'package:chepuhagram/presentation/screens/account_setup_screen.dart';
import 'package:chepuhagram/presentation/screens/key_recovery_screen.dart';
import 'package:chepuhagram/presentation/screens/chat_screen.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:chepuhagram/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -15,6 +15,9 @@ import 'dart:convert';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:cryptography/cryptography.dart';
import 'package:flutter/foundation.dart';
import 'package:chepuhagram/core/theme_manager.dart';
import 'dart:ui';
import 'dart:io';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@ -23,11 +26,16 @@ class SplashScreen extends StatefulWidget {
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
class _SplashScreenState extends State<SplashScreen>
with TickerProviderStateMixin {
int? _targetChatId;
String? connectError;
String? _statusMessage;
late AnimationController _fadeController;
late AnimationController _pulseController;
late Animation<double> _fadeAnimation;
late Animation<double> _pulseAnimation;
// Ключ для SharedPreferences
static const String _notificationLaunchKey = 'notification_launch_data';
static const String _contactPublicKey = 'contact_public_key_';
static const String _contactSharedKey = 'contact_shared_key_';
@ -35,299 +43,252 @@ class _SplashScreenState extends State<SplashScreen> {
@override
void initState() {
super.initState();
print('SplashScreen initState');
_fadeController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 800));
_pulseController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 1200));
_fadeAnimation =
Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(
parent: _fadeController,
curve: Curves.easeIn,
));
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.15)
.animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
_pulseController.repeat(reverse: true);
_fadeController.forward();
_setupNotificationHandler();
_initializeApp();
}
void _setupNotificationHandler() {
print('Setting up notification handler');
// Обработка открытия приложения из уведомления
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
print('App opened from notification: ${message.data}');
if (message.data['type'] == 'enc_message') {
final senderId = int.tryParse(
message.data['sender_id']?.toString() ?? '',
);
final senderId = int.tryParse(message.data['sender_id']?.toString() ?? '');
if (senderId != null) {
setState(() {
_targetChatId = senderId;
});
print('Set target chat from opened app: $senderId');
setState(() => _targetChatId = senderId);
}
}
});
}
Future<void> _initializeApp() async {
// 1. Искусственная задержка в 2 секунды для демонстрации splash
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
setState(() => _statusMessage = "Подключение...");
// 2. Пытаемся выполнить автологин
final authProvider = context.read<AuthProvider>();
bool? isLoggedIn;
try {
isLoggedIn = await authProvider.tryAutoLogin();
} catch (e) {
setState(() {
connectError =
'$e+_sps_init_1'.replaceAll('Exception: ', '');
});
setState(() => _statusMessage = 'Ошибка входа: ${e.toString().replaceAll('Exception: ', '')}');
await Future.delayed(const Duration(seconds: 3));
if (mounted) _navigateTo(const LoginScreen());
return;
}
if (!mounted) return;
bool connected = false;
int connectAttempt = 0;
// 3. Навигация в зависимости от результата и статуса аккаунта
if (isLoggedIn) {
setState(() => _statusMessage = "Аутентификация...");
bool connected = false;
int connectAttempt = 1;
while (!connected) {
try {
await authProvider.initRealtime();
connected = true;
} catch (e) {
setState(() => _statusMessage = 'Соединение... (попытка $connectAttempt)');
connectAttempt++;
if (e.toString().contains('timeout')) {
setState(() {
connectError =
'Превышено время ожидания.\n Проверьте интернет соеденение.\n Попытка соеденения: $connectAttempt';
});
} else if (e.toString().contains('Failed host lookup')) {
setState(() {
connectError =
'Сервер недоступен. Проверьте интернет соеденение.\n Попытка соеденения: $connectAttempt';
});
} else {
setState(() {
connectError = e.toString().replaceAll('Exception: ', '');
});
}
await Future.delayed(Duration(seconds: 2));
await Future.delayed(const Duration(seconds: 2));
}
}
setState(() => _statusMessage = "Загрузка профиля...");
await authProvider.refreshMe();
// Определяем путь пользователя
if (authProvider.needsSetup) {
// Путь А: Первичная настройка
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const AccountSetupScreen()),
);
_navigateTo(const AccountSetupScreen());
} else if (authProvider.needsKeyRecovery) {
// Путь В: Восстановление ключей
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const KeyRecoveryScreen()),
);
_navigateTo(const KeyRecoveryScreen());
} else {
// Путь Б: Нормальный вход в контакты
// Проверяем, было ли приложение запущено из уведомления
int? targetChatId =
_targetChatId; // Сначала проверяем из onMessageOpenedApp
if (targetChatId == null) {
final prefs = await SharedPreferences.getInstance();
final savedData = prefs.getString(_notificationLaunchKey);
try {
final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(authProvider.currentUserId);
await contactProvider.loadContacts(enrichContacts: false);
final myPrivKeyBase64 = await context
.read<CryptoService>()
.getPrivateKey();
if (myPrivKeyBase64 != null) {
final Map<int, String> keysToCompute = {};
for (var c in contactProvider.contacts) {
final savedKeyHex = prefs.getString(
'$_contactSharedKey${c.id}',
);
final savedPubKey = prefs.getString(
'$_contactPublicKey${c.id}',
);
if (savedKeyHex != null && savedPubKey == c.publicKey) {
final bytes = base64Decode(savedKeyHex);
contactProvider.setSharedKey(c.id, SecretKey(bytes));
} else if (c.publicKey != null) {
keysToCompute[c.id] = c.publicKey!;
}
}
print(
'Contacts with keys for isolate: ${keysToCompute.keys.toList()}',
);
final String privKey = myPrivKeyBase64;
final computedKeys = await compute(
CryptoService.computeSharedKeysTask,
{'keysMap': keysToCompute, 'privKey': privKey},
);
computedKeys.forEach((id, bytes) {
contactProvider.setSharedKey(id, SecretKey(bytes));
prefs.setString('$_contactSharedKey$id', base64Encode(bytes));
prefs.setString('$_contactPublicKey$id', keysToCompute[id]!);
});
}
} catch (e) {
print("Ошибка при загрузке контактов или вычислении ключей: $e");
}
// Если не установлено, проверяем SharedPreferences
if (savedData != null) {
try {
final data = jsonDecode(savedData) as Map<String, dynamic>;
print('Found saved notification data: $data');
final senderId = int.tryParse(
data['sender_id']?.toString() ?? '',
);
final type = data['type']?.toString();
// Поддерживаем старый payload (только sender_id) и новый (type+sender_id)
if (senderId != null && (type == null || type == 'enc_message')) {
targetChatId = senderId;
print(
'App launched from saved notification, target chat: $targetChatId',
);
}
// Очищаем сохраненные данные после использования
await prefs.remove(_notificationLaunchKey);
} catch (e) {
print('Error parsing saved notification data: $e');
await prefs.remove(_notificationLaunchKey);
}
}
// Также проверяем initialMessage как fallback
if (targetChatId == null) {
print('Checking initialMessage: $initialMessage');
if (initialMessage != null) {
print('Initial message data: ${initialMessage!.data}');
if (initialMessage!.data['type'] == 'enc_message') {
targetChatId = int.tryParse(
initialMessage!.data['sender_id']?.toString() ?? '',
);
print('Set target chat from initialMessage: $targetChatId');
} else {
print(
'Initial message type is not enc_message: ${initialMessage!.data['type']}',
);
}
} else {
print('No initial message found');
}
}
} else {
print('Using targetChatId from onMessageOpenedApp: $targetChatId');
}
if (targetChatId != null) {
print(
'Notification targetChatId resolved: $targetChatId, trying to open chat directly',
);
try {
final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(authProvider.currentUserId);
await contactProvider.loadContacts(enrichContacts: false);
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == targetChatId,
);
currentActiveChatContactId = targetChatId;
print(
'Directly navigating to ChatScreen for contact: ${contact.username}',
);
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
);
return;
} catch (e) {
print(
'Failed to open chat directly, falling back to ContactsScreen: $e',
);
}
}
print('Navigating to ContactsScreen, targetChatId: $targetChatId');
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey);
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => ContactsScreen(targetChatId: targetChatId),
),
);
setState(() => _statusMessage = "Загрузка контактов...");
_loadContactsAndNavigate(authProvider.currentUserId);
}
} else {
// Нет токена - переходим на экран входа
_navigateTo(const LoginScreen());
}
}
Future<void> _loadContactsAndNavigate(int? currentUserId) async {
// Navigate to ContactsScreen while contacts are loading in the background
_navigateTo(ContactsScreen(targetChatId: await _getTargetChatId()));
try {
final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(currentUserId);
await contactProvider.loadContacts(enrichContacts: false);
final prefs = await SharedPreferences.getInstance();
final myPrivKeyBase64 = await context.read<CryptoService>().getPrivateKey();
if (myPrivKeyBase64 != null) {
final Map<int, String> keysToCompute = {};
for (var c in contactProvider.contacts) {
final savedKeyHex = prefs.getString('$_contactSharedKey${c.id}');
final savedPubKey = prefs.getString('$_contactPublicKey${c.id}');
if (savedKeyHex != null && savedPubKey == c.publicKey) {
contactProvider.setSharedKey(c.id, SecretKey(base64Decode(savedKeyHex)));
} else if (c.publicKey != null) {
keysToCompute[c.id] = c.publicKey!;
}
}
final computedKeys = await compute(
CryptoService.computeSharedKeysTask,
{'keysMap': keysToCompute, 'privKey': myPrivKeyBase64},
);
computedKeys.forEach((id, bytes) {
contactProvider.setSharedKey(id, SecretKey(bytes));
prefs.setString('$_contactSharedKey$id', base64Encode(bytes));
prefs.setString('$_contactPublicKey$id', keysToCompute[id]!);
});
}
} catch (e) {
print("Ошибка при фоновой загрузке контактов или ключей: $e");
}
}
Future<int?> _getTargetChatId() async {
int? targetChatId = _targetChatId;
final prefs = await SharedPreferences.getInstance();
if (targetChatId == null) {
final savedData = prefs.getString(_notificationLaunchKey);
if (savedData != null) {
try {
final data = jsonDecode(savedData) as Map<String, dynamic>;
targetChatId = int.tryParse(data['sender_id']?.toString() ?? '');
} catch (e) {
print('Error parsing saved notification data: $e');
}
}
}
if (targetChatId == null && initialMessage != null) {
if (initialMessage!.data['type'] == 'enc_message') {
targetChatId = int.tryParse(initialMessage!.data['sender_id']?.toString() ?? '');
}
}
await prefs.remove(_notificationLaunchKey);
return targetChatId;
}
void _navigateTo(Widget screen) {
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const LoginScreen()),
MaterialPageRoute(builder: (_) => screen),
);
}
}
@override
void dispose() {
_fadeController.dispose();
_pulseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final themeProv = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(),
Icon(
Icons.messenger_outline,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 24),
Text(
"Chepuhagram",
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
body: Stack(
children: [
if (themeProv.wallpaperPath != null)
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: FileImage(File(themeProv.wallpaperPath!)),
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 40),
// Мягкий индикатор загрузки снизу
CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
if (themeProv.wallpaperPath != null)
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: Container(color: Colors.black.withOpacity(0.1)),
),
const SizedBox(height: 40),
Text(
connectError ?? '',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 14,
),
textAlign: TextAlign.center,
),
const Spacer(),
Text(
'Made by ArturKarasevich',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
Center(
child: FadeTransition(
opacity: _fadeAnimation,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(),
ScaleTransition(
scale: _pulseAnimation,
child: Icon(
Icons.messenger_outline,
size: 80,
color: colorScheme.primary,
),
),
const SizedBox(height: 24),
Text(
"Chepuhagram",
style: TextStyle(
color: colorScheme.primary,
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
const SizedBox(height: 80),
SizedBox(
height: 40,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _statusMessage != null
? Text(
_statusMessage!,
key: ValueKey(_statusMessage),
style: TextStyle(
color: colorScheme.outline,
fontSize: 14,
),
textAlign: TextAlign.center,
)
: const SizedBox.shrink(),
),
),
const CircularProgressIndicator(),
const Spacer(),
Text(
'Made by ArturKarasevich',
style: TextStyle(
color: colorScheme.outline,
fontSize: 12,
),
),
const SizedBox(height: 40),
],
),
),
const SizedBox(height: 80),
],
),
),
],
),
);
}

View File

@ -1,4 +1,6 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '/data/models/message_model.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:url_launcher/url_launcher.dart';
@ -22,6 +24,8 @@ class MessageBubble extends StatefulWidget {
final VoidCallback? onTap;
final VoidCallback? onReplyTap;
final VoidCallback? onImageTap;
final VoidCallback? onEditTap;
final VoidCallback? onDeleteTap;
final Future<void>? Function(MessageModel)? onDownloadRequested;
final Future<void>? Function(MessageModel)? onDownloadRequestedWithoutLoad;
@ -35,6 +39,8 @@ class MessageBubble extends StatefulWidget {
this.onTap,
this.onReplyTap,
this.onImageTap,
this.onEditTap,
this.onDeleteTap,
this.onDownloadRequested,
this.onDownloadRequestedWithoutLoad,
this.onDownloadStoped,
@ -529,6 +535,103 @@ class _MessageBubbleState extends State<MessageBubble> {
widget.message.localFile!.existsSync();
}
void _showContextMenu(TapDownDetails details) {
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final RelativeRect position = RelativeRect.fromRect(
Rect.fromLTWH(details.globalPosition.dx, details.globalPosition.dy, 30, 30),
Offset.zero & overlay.size);
showMenu<String>(
context: context,
position: position,
items: [
const PopupMenuItem<String>(
value: 'reply',
child: Text('Reply'),
),
if (widget.message.text.isNotEmpty)
const PopupMenuItem<String>(
value: 'copy',
child: Text('Copy Text'),
),
if (widget.message.isMe)
const PopupMenuItem<String>(
value: 'edit',
child: Text('Edit'),
),
if (widget.message.isMe)
const PopupMenuItem<String>(
value: 'delete',
child: Text('Delete'),
),
],
).then((String? value) {
if (value != null) {
_handleMenuSelection(value);
}
});
}
void _handleMenuSelection(String value) {
switch (value) {
case 'reply':
widget.onReplyTap?.call();
break;
case 'copy':
Clipboard.setData(ClipboardData(text: widget.message.text));
break;
case 'edit':
widget.onEditTap?.call();
break;
case 'delete':
widget.onDeleteTap?.call();
break;
}
}
TextSpan _buildTextSpan(String text, Color primaryColor, Color linkColor, double fontSize) {
final List<InlineSpan> children = [];
final RegExp linkRegExp = RegExp(
r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+');
final matches = linkRegExp.allMatches(text);
int lastMatchEnd = 0;
for (final Match match in matches) {
if (match.start > lastMatchEnd) {
children.add(TextSpan(text: text.substring(lastMatchEnd, match.start)));
}
final String linkText = match.group(0)!;
children.add(
TextSpan(
text: linkText,
style: TextStyle(color: linkColor, fontWeight: FontWeight.bold, decoration: TextDecoration.underline),
recognizer: TapGestureRecognizer()
..onTap = () async {
String url = linkText;
if (!url.startsWith('http')) {
url = 'https://$url';
}
final Uri uri = Uri.parse(url);
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
throw Exception('Could not launch $uri');
}
},
),
);
lastMatchEnd = match.end;
}
if (lastMatchEnd < text.length) {
children.add(TextSpan(text: text.substring(lastMatchEnd)));
}
return TextSpan(
style: TextStyle(color: primaryColor, fontSize: fontSize),
children: children,
);
}
@override
Widget build(BuildContext context) {
final isMe = widget.message.isMe;
@ -550,69 +653,68 @@ class _MessageBubbleState extends State<MessageBubble> {
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
onLongPress: widget.onTap,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isMe ? 16 : 0),
bottomRight: Radius.circular(isMe ? 0 : 16),
),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
padding: EdgeInsets.symmetric(vertical: paddingVertical, horizontal: paddingHorizontal),
constraints: BoxConstraints(
// Максимальная ширина на больших мониторах ограничена 460px (как в Telegram Desktop)
maxWidth: math.min(screenWidth * 0.75, 460.0),
child: GestureDetector(
onSecondaryTapDown: _showContextMenu,
onLongPressStart: (details) {
final tapDownDetails =
TapDownDetails(globalPosition: details.globalPosition);
_showContextMenu(tapDownDetails);
},
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isMe ? 16 : 0),
bottomRight: Radius.circular(isMe ? 0 : 16),
),
decoration: BoxDecoration(
color: isMe
? Theme.of(context).colorScheme.brightness == Brightness.dark
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.primary
: Colors.grey[800],
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isMe ? 16 : 0),
bottomRight: Radius.circular(isMe ? 0 : 16),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
padding: EdgeInsets.symmetric(vertical: paddingVertical, horizontal: paddingHorizontal),
constraints: BoxConstraints(
// Максимальная ширина на больших мониторах ограничена 460px (как в Telegram Desktop)
maxWidth: math.min(screenWidth * 0.75, 460.0),
),
),
child: IntrinsicWidth(
child: Column(
crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
if (widget.message.replyToText != null) ...[
_buildReplyWidget(isMe, secondaryTextColor, replyFontSize),
],
Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: _buildMessageBody(primaryTextColor, secondaryTextColor, isLargeScreen),
),
if (widget.message.messageType == MessageType.text ||
widget.message.text.isNotEmpty) ...[
const SizedBox(height: 4),
decoration: BoxDecoration(
color: isMe
? Theme.of(context).colorScheme.brightness == Brightness.dark
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.primary
: Colors.grey[800],
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isMe ? 16 : 0),
bottomRight: Radius.circular(isMe ? 0 : 16),
),
),
child: IntrinsicWidth(
child: Column(
crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
if (widget.message.replyToText != null) ...[
_buildReplyWidget(isMe, secondaryTextColor, replyFontSize),
],
Align(
alignment: Alignment.centerLeft,
child: Linkify(
onOpen: (link) async {
final Uri url = Uri.parse(link.url);
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
throw Exception('Could not launch $url');
}
},
text: widget.message.text,
style: TextStyle(color: primaryTextColor, fontSize: bodyFontSize),
linkStyle: TextStyle(color: linkColor, fontWeight: FontWeight.bold),
),
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: _buildMessageBody(primaryTextColor, secondaryTextColor, isLargeScreen),
),
if (widget.message.text.isNotEmpty) ...[
const SizedBox(height: 4),
Align(
alignment: Alignment.centerLeft,
child: SelectableText.rich(
_buildTextSpan(widget.message.text, primaryTextColor, linkColor, bodyFontSize),
style: TextStyle(color: primaryTextColor, fontSize: bodyFontSize),
),
),
],
const SizedBox(height: 4),
_buildTimeAndStatusRow(isMe, secondaryTextColor, timeFontSize),
],
const SizedBox(height: 4),
_buildTimeAndStatusRow(isMe, secondaryTextColor, timeFontSize),
],
),
),
),
),
@ -634,6 +736,11 @@ class _MessageBubbleState extends State<MessageBubble> {
case MessageType.voiceNote:
return _buildVoiceNoteBubble(primaryColor, secondaryColor, isLargeScreen);
default:
// For text-only messages, we don't need a body here as it's handled outside.
if (widget.message.messageType == MessageType.text) {
return const SizedBox.shrink();
}
// Fallback for any other case
return const SizedBox.shrink();
}
}
@ -1576,7 +1683,8 @@ class _InlineVideoInitErrorFallback extends StatelessWidget {
children: [
Icon(Icons.play_disabled, color: Colors.white70, size: 40),
SizedBox(height: 8),
Text('Видео не воспроизводится\n Нажмите, чтобы открыть внешним плеером', textAlign: TextAlign.center, style: TextStyle(color: Colors.white70, fontSize: 12)),
Text('Видео не воспроизводится
Нажмите, чтобы открыть внешним плеером', textAlign: TextAlign.center, style: TextStyle(color: Colors.white70, fontSize: 12)),
],
),
),
@ -1876,4 +1984,4 @@ class _InlineVoiceNotePlayerState extends State<InlineVoiceNotePlayer> {
),
);
}
}
}

View File

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB