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; return true;
} }
Future<bool> updateProfileAndSecurity({ Future<bool> setupAccount(
required String firstName, String firstName,
String? lastName, String? lastName,
required String masterPassword, String masterPassword,
}) async { ) async {
notifyListeners(); notifyListeners();
try { try {

View File

@ -1,7 +1,10 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../logic/auth_provider.dart'; import 'package:chepuhagram/logic/auth_provider.dart';
import 'contacts_screen.dart'; import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
import 'package:chepuhagram/core/theme_manager.dart';
import 'dart:io';
class AccountSetupScreen extends StatefulWidget { class AccountSetupScreen extends StatefulWidget {
const AccountSetupScreen({super.key}); const AccountSetupScreen({super.key});
@ -10,147 +13,145 @@ class AccountSetupScreen extends StatefulWidget {
State<AccountSetupScreen> createState() => _AccountSetupScreenState(); State<AccountSetupScreen> createState() => _AccountSetupScreenState();
} }
class _AccountSetupScreenState extends State<AccountSetupScreen> { class _AccountSetupScreenState extends State<AccountSetupScreen>
with SingleTickerProviderStateMixin {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _firstNameController = TextEditingController(); final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController(); final _lastNameController = TextEditingController();
final _masterPasswordController = TextEditingController(); final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController(); final _confirmPasswordController = TextEditingController();
bool _isLoading = false;
String? _errorMessage; late AnimationController _animationController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override @override
void dispose() { void initState() {
_firstNameController.dispose(); super.initState();
_lastNameController.dispose(); _animationController = AnimationController(
_masterPasswordController.dispose(); vsync: this,
_confirmPasswordController.dispose(); duration: const Duration(milliseconds: 900),
super.dispose();
}
Future<void> _setupAccount() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
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) { _fadeAnimation =
// Переходим на экран контактов Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(
Navigator.pushReplacement( parent: _animationController,
context, curve: Curves.easeIn,
MaterialPageRoute(builder: (_) => const ContactsScreen()), ));
);
} else if (mounted) { _slideAnimation =
setState(() { Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero).animate(
_errorMessage = 'Ошибка при сохранении профиля. Попробуйте еще раз.'; CurvedAnimation(
_isLoading = false; parent: _animationController,
}); curve: Curves.fastOutSlowIn,
} ));
} catch (e) {
if (mounted) { _animationController.forward();
setState(() {
_errorMessage = 'Ошибка: ${e.toString()}';
_isLoading = false;
});
}
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final authProvider = context.watch<AuthProvider>();
final themeProv = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
appBar: AppBar( body: Stack(
title: const Text('Завершение настройки'), children: [
centerTitle: true, // Background Wallpaper
elevation: 0, if (themeProv.wallpaperPath != null)
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: FileImage(File(themeProv.wallpaperPath!)),
fit: BoxFit.cover,
), ),
body: SingleChildScrollView( ),
),
// Blur Overlay
if (themeProv.wallpaperPath != null)
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: Container(
color: Colors.black.withOpacity(0.1),
),
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ 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), const SizedBox(height: 16),
Text( Text(
'Завершите настройку вашего профиля', "Настройка аккаунта",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall, style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
), ),
const SizedBox(height: 8), ),
const SizedBox(height: 12),
Text( Text(
'Введите ваше имя, фамилию и создайте мастер-пароль. Мастер-пароль будет использоваться для защиты ваших ключей шифрования.', "Укажите ваше имя и создайте мастер-пароль",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: TextStyle(
color: Theme.of(context).textTheme.bodySmall?.color, fontSize: 16,
color: colorScheme.outline,
), ),
), ),
const SizedBox(height: 32), ],
),
),
),
const SizedBox(height: 48),
// Поле Имя // Поле Имя
TextFormField( _buildTextField(
controller: _firstNameController, controller: _firstNameController,
decoration: InputDecoration( label: "Имя",
labelText: 'Имя *', icon: Icons.person_outline,
prefixIcon: const Icon(Icons.person_outline), validator: (value) =>
border: OutlineInputBorder( value!.isEmpty ? "Введите ваше имя" : null,
borderRadius: BorderRadius.circular(12),
),
hintText: 'Введите ваше имя',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Имя не может быть пустым';
}
return null;
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Поле Фамилия // Поле Фамилия
TextFormField( _buildTextField(
controller: _lastNameController, controller: _lastNameController,
decoration: InputDecoration( label: "Фамилия",
labelText: 'Фамилия', icon: Icons.person_outline,
prefixIcon: const Icon(Icons.person_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
), ),
hintText: 'Введите вашу фамилию (опционально)', const SizedBox(height: 24),
),
),
const SizedBox(height: 16),
// Поле Мастер-пароль // Поле Мастер-пароль
TextFormField( _buildTextField(
controller: _masterPasswordController, controller: _passwordController,
label: "Мастер-пароль",
icon: Icons.lock_outline,
obscureText: true, obscureText: true,
decoration: InputDecoration(
labelText: 'Мастер-пароль *',
prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
hintText: 'Создайте надежный пароль',
),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return 'Мастер-пароль не может быть пустым'; return 'Введите мастер-пароль';
} }
if (value.length < 8) { if (value.length < 8) {
return 'Пароль должен содержать минимум 8 символов'; return 'Пароль должен быть не менее 8 символов';
} }
return null; return null;
}, },
@ -158,77 +159,130 @@ class _AccountSetupScreenState extends State<AccountSetupScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Поле Подтверждение пароля // Поле Подтверждение пароля
TextFormField( _buildTextField(
controller: _confirmPasswordController, controller: _confirmPasswordController,
label: "Подтвердите пароль",
icon: Icons.lock_person_outlined,
obscureText: true, obscureText: true,
decoration: InputDecoration(
labelText: 'Подтвердите пароль *',
prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
hintText: 'Повторите пароль',
),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value != _passwordController.text) {
return 'Подтвердите пароль';
}
if (value != _masterPasswordController.text) {
return 'Пароли не совпадают'; return 'Пароли не совпадают';
} }
return null; return null;
}, },
), ),
const SizedBox(height: 24), const SizedBox(height: 40),
// Сообщение об ошибке // Кнопка Продолжить
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( ElevatedButton(
onPressed: _isLoading ? null : _setupAccount, onPressed: authProvider.isLoading ? null : _submit,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
), ),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
), ),
child: _isLoading child: authProvider.isLoading
? const SizedBox( ? const SizedBox(
height: 20, height: 20,
width: 20, width: 20,
child: CircularProgressIndicator(strokeWidth: 2), child:
CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
) )
: const Text( : const Text("Создать аккаунт",
'Завершить настройку', style: TextStyle(
style: TextStyle(fontSize: 16), fontSize: 16, fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 24),
Text(
'Сохраните мастер-пароль в надежном месте. Он потребуется для восстановления ключей шифрования при переустановке приложения.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
), ),
], ],
), ),
), ),
), ),
),
],
),
); );
} }
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(), : const SizedBox.shrink(),
title: Consumer<ContactProvider>( title: Consumer<ContactProvider>(
builder: (context, contactProvider, child) { builder: (context, contactProvider, child) {
// Реактивно отслеживаем изменения пользователя в провайдере (например, аватарку)
final freshContact = contactProvider.contacts.firstWhere( final freshContact = contactProvider.contacts.firstWhere(
(c) => c.id == widget.contact.id, (c) => c.id == widget.contact.id,
orElse: () => widget.contact, orElse: () => widget.contact,
); );
// ФИКС ОНЛАЙНА: Определяем статус на основе встроенного таймера экрана чата
final bool currentOnline = freshContact.isOnline || _isOnline; final bool currentOnline = freshContact.isOnline || _isOnline;
final String subtitleText = currentOnline final String subtitleText = currentOnline
? 'в сети' ? 'в сети'
@ -605,6 +603,18 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
lName = ''; 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(); final String cleanFullName = '$fName $lName'.trim();
return InkWell( return InkWell(
onTap: () { onTap: () {
@ -635,9 +645,7 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: Theme.of( color: Theme.of(context).colorScheme.primaryContainer,
context,
).colorScheme.primaryContainer.withOpacity(0.4),
image: freshContact.avatarUrl != null image: freshContact.avatarUrl != null
? DecorationImage( ? DecorationImage(
image: NetworkImage(freshContact.avatarUrl!), image: NetworkImage(freshContact.avatarUrl!),
@ -647,14 +655,17 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
), ),
child: freshContact.avatarUrl == null child: freshContact.avatarUrl == null
? Center( ? Center(
child: Padding(
padding: const EdgeInsets.only(bottom: 1),
child: Text( child: Text(
_currentContact.name.isNotEmpty contactInitials,
? _currentContact.name[0].toUpperCase()
: '?',
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary, 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 localName = _localFullNames[contact.id];
final displayName = (localName != null && localName.isNotEmpty) final displayName = (localName != null && localName.isNotEmpty)
? localName ? localName
: contact.name; : '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim();
final contactInitials = displayName.isNotEmpty final contactInitials = displayName.isNotEmpty
? displayName ? displayName
@ -402,7 +402,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
height: 52, height: 52,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: colorScheme.primary.withOpacity(0.08), color: colorScheme.primaryContainer,
), ),
child: ClipOval( child: ClipOval(
child: Stack( child: Stack(
@ -411,9 +411,9 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
Text( Text(
contactInitials, contactInitials,
style: TextStyle( style: TextStyle(
color: colorScheme.primary, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 18, color: colorScheme.onPrimaryContainer,
), ),
), ),
if (contact.avatarUrl != null) if (contact.avatarUrl != null)
@ -1020,7 +1020,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
if (didPop) return; if (didPop) return;
if (_selectedContact != null && isPhoneFormFactor) { if (_selectedContact != null && isPhoneFormFactor) {
_clearSelectedContact(); // Плавно закрываем чат и возвращаемся к списку _clearSelectedContact();
} }
}, },
child: _buildResponsiveBody(isPhoneFormFactor), child: _buildResponsiveBody(isPhoneFormFactor),

View File

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

View File

@ -1,13 +1,16 @@
import 'package:chepuhagram/presentation/screens/contacts_screen.dart'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../logic/auth_provider.dart'; import 'package:chepuhagram/logic/auth_provider.dart';
import '../../domain/services/crypto_service.dart'; import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
import '../../domain/services/api_service.dart'; import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'account_setup_screen.dart'; import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:chepuhagram/presentation/screens/account_setup_screen.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; 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 { class KeyRecoveryScreen extends StatefulWidget {
const KeyRecoveryScreen({super.key}); const KeyRecoveryScreen({super.key});
@ -16,12 +19,40 @@ class KeyRecoveryScreen extends StatefulWidget {
State<KeyRecoveryScreen> createState() => _KeyRecoveryScreenState(); State<KeyRecoveryScreen> createState() => _KeyRecoveryScreenState();
} }
class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> { class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> with SingleTickerProviderStateMixin {
bool _isLoading = false; bool _isLoading = false;
String? _errorMessage; String? _errorMessage;
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>(); 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 { Future<void> _startFresh() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@ -31,19 +62,15 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
try { try {
final authProvider = context.read<AuthProvider>(); final authProvider = context.read<AuthProvider>();
// Удаляем все сообщения пользователя
try { try {
final api = ApiService(); final api = ApiService();
await api.deleteAllMessages(); await api.deleteAllMessages();
} catch (e) { } catch (e) {
print('Ошибка при удалении сообщений: $e'); print('Ошибка при удалении сообщений: $e');
// Продолжаем даже если удаление сообщений не удалось
} }
// Удаляем старые ключи и создаем новые
await authProvider.resetKeys(); await authProvider.resetKeys();
// Переходим на экран настройки для создания новых ключей
if (mounted) { if (mounted) {
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
@ -62,6 +89,7 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
Future<void> _recoverKeys() async { Future<void> _recoverKeys() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus();
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@ -73,11 +101,9 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
final apiService = ApiService(); final apiService = ApiService();
final cryptoService = CryptoService(); final cryptoService = CryptoService();
// Получаем токен
final token = await apiService.getAccessToken(); final token = await apiService.getAccessToken();
if (token == null) throw Exception('Не авторизован'); if (token == null) throw Exception('Не авторизован');
// Скачиваем зашифрованный приватный ключ с сервера
final response = await http.get( final response = await http.get(
Uri.parse('${AppConstants.baseUrl}/users/me'), Uri.parse('${AppConstants.baseUrl}/users/me'),
headers: {'Authorization': 'Bearer $token'}, headers: {'Authorization': 'Bearer $token'},
@ -94,20 +120,16 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
throw Exception('Зашифрованный ключ не найден на сервере'); throw Exception('Зашифрованный ключ не найден на сервере');
} }
// Расшифровываем приватный ключ
final decryptedPrivateKey = await cryptoService.decryptPrivateKey( final decryptedPrivateKey = await cryptoService.decryptPrivateKey(
encryptedPrivateKey, encryptedPrivateKey,
_passwordController.text, _passwordController.text,
); );
// Сохраняем расшифрованный ключ локально
await cryptoService.savePrivateKey(decryptedPrivateKey); await cryptoService.savePrivateKey(decryptedPrivateKey);
// Обновляем статус в AuthProvider
await authProvider.tryAutoLogin(); await authProvider.tryAutoLogin();
if (mounted) { if (mounted) {
// Возвращаемся на главный экран
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute(builder: (_) => const ContactsScreen()), MaterialPageRoute(builder: (_) => const ContactsScreen()),
@ -125,94 +147,184 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeProv = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
appBar: AppBar( body: Stack(
title: const Text('Восстановление ключей'), children: [
centerTitle: true, if (themeProv.wallpaperPath != null)
elevation: 0, Container(
decoration: BoxDecoration(
image: DecorationImage(
image: FileImage(File(themeProv.wallpaperPath!)),
fit: BoxFit.cover,
), ),
body: SingleChildScrollView( ),
),
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), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const SizedBox(height: 32), const SizedBox(height: 32),
SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: Column(
children: [
Icon( Icon(
Icons.security_outlined, Icons.security_outlined,
size: 80, size: 80,
color: Theme.of(context).colorScheme.primary, color: colorScheme.primary,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'Восстановление ключей шифрования', 'Восстановление ключей',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall, style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Вы переустановили приложение или используете новый девайс. У вас есть два варианта:', 'Вы переустановили приложение или вошли на новом устройстве. Выберите один из вариантов:',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium, style: TextStyle(fontSize: 16, color: colorScheme.outline),
),
],
),
),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
// Вариант 1: Начать с чистого листа // Вариант 1: Начать с чистого листа
Card( _buildOptionCard(
child: Padding( icon: Icons.restart_alt_outlined,
padding: const EdgeInsets.all(16.0), 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,
),
),
],
),
),
),
],
),
);
}
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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
Icon( Icon(icon, color: colorScheme.primary, size: 28),
Icons.restart_alt_outlined,
color: Theme.of(context).colorScheme.primary,
size: 28,
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
'Начать с чистого листа', title,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
fontWeight: FontWeight.bold,
),
), ),
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(description, style: TextStyle(color: colorScheme.outline, fontSize: 14)),
'Создаются новые ключи шифрования. Старые сообщения не будут расшифрованы.', const SizedBox(height: 20),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
onPressed: _isLoading ? null : _startFresh, onPressed: _isLoading ? null : onPressed,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
), ),
child: _isLoading child: _isLoading
? const SizedBox( ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
height: 20, : Text(buttonText, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Продолжить'),
), ),
), ),
], ],
), ),
), ),
), ),
const SizedBox(height: 24), );
}
// Вариант 2: Восстановить из облака Widget _buildRecoveryCard() {
Card( final colorScheme = Theme.of(context).colorScheme;
child: Padding( return ClipRRect(
padding: const EdgeInsets.all(16.0), 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( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
@ -220,18 +332,12 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
children: [ children: [
Row( Row(
children: [ children: [
Icon( Icon(Icons.cloud_download_outlined, color: colorScheme.primary, size: 28),
Icons.cloud_download_outlined,
color: Theme.of(context).colorScheme.primary,
size: 28,
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( const Expanded(
child: Text( child: Text(
'Восстановить из облака', 'Восстановить из облака',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
fontWeight: FontWeight.bold,
),
), ),
), ),
], ],
@ -239,38 +345,30 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'Введите мастер-пароль для восстановления ключей шифрования', 'Введите мастер-пароль для восстановления ключей шифрования',
style: Theme.of(context).textTheme.bodySmall, style: TextStyle(color: colorScheme.outline, fontSize: 14),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( _buildTextField(
controller: _passwordController, controller: _passwordController,
label: "Мастер-пароль",
icon: Icons.lock_outline,
obscureText: true, obscureText: true,
decoration: const InputDecoration( validator: (value) => value!.isEmpty ? "Введите мастер-пароль" : null,
labelText: 'Мастер-пароль',
border: OutlineInputBorder(),
), ),
validator: (value) { const SizedBox(height: 20),
if (value == null || value.isEmpty) {
return 'Введите мастер-пароль';
}
return null;
},
),
const SizedBox(height: 16),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
onPressed: _isLoading ? null : _recoverKeys, onPressed: _isLoading ? null : _recoverKeys,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
), ),
child: _isLoading child: _isLoading
? const SizedBox( ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
height: 20, : const Text('Восстановить', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Восстановить'),
), ),
), ),
], ],
@ -278,32 +376,45 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
), ),
), ),
), ),
const SizedBox(height: 24), );
}
// Сообщение об ошибке Widget _buildTextField({
if (_errorMessage != null) required TextEditingController controller,
Container( required String label,
padding: const EdgeInsets.all(12), required IconData icon,
bool obscureText = false,
String? Function(String?)? validator,
}) {
final colorScheme = Theme.of(context).colorScheme;
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container( // No blur here, it's inside the card
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error.withOpacity(0.1), color: colorScheme.surface.withOpacity(0.5),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(16),
), ),
child: Text( child: TextFormField(
_errorMessage!, controller: controller,
style: TextStyle( obscureText: obscureText,
color: Theme.of(context).colorScheme.error, 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),
), ),
), ),
), ),
],
),
),
); );
} }
@override @override
void dispose() { void dispose() {
_passwordController.dispose(); _passwordController.dispose();
_animationController.dispose();
super.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/contacts_screen.dart';
import 'package:chepuhagram/presentation/screens/account_setup_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/key_recovery_screen.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../logic/auth_provider.dart'; import '../../logic/auth_provider.dart';
import '/core/theme_manager.dart';
import 'dart:io'; // Import for File
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
const LoginScreen({super.key}); 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 _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController(); final _usernameController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
@ -20,12 +23,63 @@ class _LoginScreenState extends State<LoginScreen> {
bool _showTotpField = false; bool _showTotpField = false;
String? _errorMessage; 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) { Widget build(BuildContext context) {
final authProvider = context.watch<AuthProvider>(); final authProvider = context.watch<AuthProvider>();
final themeProv = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
body: Center( body: Stack(
children: [
// Background Wallpaper
if (themeProv.wallpaperPath != null)
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: FileImage(File(themeProv.wallpaperPath!)),
fit: BoxFit.cover,
),
),
),
// Blur Overlay
if (themeProv.wallpaperPath != null)
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: Container(
color: Colors.black.withOpacity(0.1),
),
),
Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: Form( child: Form(
@ -34,11 +88,16 @@ class _LoginScreenState extends State<LoginScreen> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Иконка SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: Column(
children: [
Icon( Icon(
Icons.messenger_outline, Icons.messenger_outline,
size: 80, size: 80,
color: Theme.of(context).colorScheme.primary, color: colorScheme.primary,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
@ -47,44 +106,31 @@ class _LoginScreenState extends State<LoginScreen> {
style: TextStyle( style: TextStyle(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary, color: colorScheme.primary,
), ),
), ),
const SizedBox(height: 32), ],
),
),
),
const SizedBox(height: 48),
// Поле Логин // Поле Логин
TextFormField( _buildTextField(
controller: _usernameController, controller: _usernameController,
decoration: InputDecoration( label: "Логин",
labelText: "Логин", icon: Icons.person_outline,
prefixIcon: const Icon(Icons.person_outline), validator: (value) =>
border: OutlineInputBorder( value!.isEmpty ? "Введите логин" : null,
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), const SizedBox(height: 16),
// Поле Пароль // Поле Пароль
TextFormField( _buildTextField(
controller: _passwordController, controller: _passwordController,
label: "Пароль",
icon: Icons.lock_outline,
obscureText: true, 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) => validator: (value) =>
value!.length < 6 ? "Минимум 6 символов" : null, value!.length < 6 ? "Минимум 6 символов" : null,
), ),
@ -92,30 +138,32 @@ class _LoginScreenState extends State<LoginScreen> {
// Поле TOTP, если требуется // Поле TOTP, если требуется
if (_showTotpField) if (_showTotpField)
TextFormField( _buildTextField(
controller: _totpController, controller: _totpController,
decoration: InputDecoration( label: "TOTP код",
labelText: "TOTP код", icon: Icons.security,
prefixIcon: const Icon(Icons.security), validator: (value) =>
border: OutlineInputBorder( value!.isEmpty ? "Введите TOTP код" : null,
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 ? "Введите TOTP код" : null,
), ),
if (_showTotpField) const SizedBox(height: 16), if (_showTotpField) const SizedBox(height: 16),
// Сообщение об ошибке // Сообщение об ошибке
if (_errorMessage != null) if (_errorMessage != null)
Text( Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_errorMessage!, _errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.error), style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.w500),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
),
if (_errorMessage != null) const SizedBox(height: 16), if (_errorMessage != null) const SizedBox(height: 16),
const SizedBox(height: 24), const SizedBox(height: 24),
@ -124,31 +172,82 @@ class _LoginScreenState extends State<LoginScreen> {
ElevatedButton( ElevatedButton(
onPressed: authProvider.isLoading ? null : _submit, onPressed: authProvider.isLoading ? null : _submit,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
), ),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
), ),
child: authProvider.isLoading child: authProvider.isLoading
? const SizedBox( ? const SizedBox(
height: 20, height: 20,
width: 20, width: 20,
child: CircularProgressIndicator(strokeWidth: 2), child:
CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
) )
: const Text("Войти", style: TextStyle(fontSize: 16)), : 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),
),
),
),
),
); );
} }
void _submit() async { void _submit() async {
try { // Сначала убираем фокус с полей, чтобы клавиатура скрылась
FocusScope.of(context).unfocus();
await Future.delayed(const Duration(milliseconds: 200));
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
try {
final authProvider = context.read<AuthProvider>(); final authProvider = context.read<AuthProvider>();
final success = await authProvider.login( final success = await authProvider.login(
_usernameController.text, _usernameController.text,
@ -181,24 +280,23 @@ class _LoginScreenState extends State<LoginScreen> {
} }
} catch (e) { } catch (e) {
final error = e.toString().replaceAll('Exception: ', ''); final error = e.toString().replaceAll('Exception: ', '');
if (error.contains('TOTP код требуется')) { if (mounted) {
setState(() { setState(() {
_showTotpField = true;
_errorMessage = error; _errorMessage = error;
if (error.contains('TOTP код требуется')) {
_showTotpField = true;
}
}); });
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
} }
} }
} }
@override
void dispose() { void dispose() {
_usernameController.dispose(); _usernameController.dispose();
_passwordController.dispose(); _passwordController.dispose();
_totpController.dispose(); _totpController.dispose();
_animationController.dispose();
super.dispose(); super.dispose();
} }
} }

View File

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

View File

@ -1,13 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../logic/auth_provider.dart'; import 'package:chepuhagram/logic/auth_provider.dart';
import '../../logic/contact_provider.dart'; import 'package:chepuhagram/logic/contact_provider.dart';
import 'login_screen.dart'; import 'package:chepuhagram/presentation/screens/login_screen.dart';
import 'contacts_screen.dart'; import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
import 'account_setup_screen.dart'; import 'package:chepuhagram/presentation/screens/account_setup_screen.dart';
import 'key_recovery_screen.dart'; import 'package:chepuhagram/presentation/screens/key_recovery_screen.dart';
import 'chat_screen.dart'; import 'package:chepuhagram/presentation/screens/chat_screen.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:chepuhagram/main.dart'; import 'package:chepuhagram/main.dart';
import 'package:shared_preferences/shared_preferences.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:chepuhagram/domain/services/crypto_service.dart';
import 'package:cryptography/cryptography.dart'; import 'package:cryptography/cryptography.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:chepuhagram/core/theme_manager.dart';
import 'dart:ui';
import 'dart:io';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@ -23,11 +26,16 @@ class SplashScreen extends StatefulWidget {
State<SplashScreen> createState() => _SplashScreenState(); State<SplashScreen> createState() => _SplashScreenState();
} }
class _SplashScreenState extends State<SplashScreen> { class _SplashScreenState extends State<SplashScreen>
with TickerProviderStateMixin {
int? _targetChatId; 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 _notificationLaunchKey = 'notification_launch_data';
static const String _contactPublicKey = 'contact_public_key_'; static const String _contactPublicKey = 'contact_public_key_';
static const String _contactSharedKey = 'contact_shared_key_'; static const String _contactSharedKey = 'contact_shared_key_';
@ -35,137 +43,116 @@ class _SplashScreenState extends State<SplashScreen> {
@override @override
void initState() { void initState() {
super.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(); _setupNotificationHandler();
_initializeApp(); _initializeApp();
} }
void _setupNotificationHandler() { void _setupNotificationHandler() {
print('Setting up notification handler');
// Обработка открытия приложения из уведомления
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
print('App opened from notification: ${message.data}');
if (message.data['type'] == 'enc_message') { if (message.data['type'] == 'enc_message') {
final senderId = int.tryParse( final senderId = int.tryParse(message.data['sender_id']?.toString() ?? '');
message.data['sender_id']?.toString() ?? '',
);
if (senderId != null) { if (senderId != null) {
setState(() { setState(() => _targetChatId = senderId);
_targetChatId = senderId;
});
print('Set target chat from opened app: $senderId');
} }
} }
}); });
} }
Future<void> _initializeApp() async { Future<void> _initializeApp() async {
// 1. Искусственная задержка в 2 секунды для демонстрации splash
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return; if (!mounted) return;
setState(() => _statusMessage = "Подключение...");
// 2. Пытаемся выполнить автологин
final authProvider = context.read<AuthProvider>(); final authProvider = context.read<AuthProvider>();
bool? isLoggedIn; bool? isLoggedIn;
try { try {
isLoggedIn = await authProvider.tryAutoLogin(); isLoggedIn = await authProvider.tryAutoLogin();
} catch (e) { } catch (e) {
setState(() { setState(() => _statusMessage = 'Ошибка входа: ${e.toString().replaceAll('Exception: ', '')}');
connectError = await Future.delayed(const Duration(seconds: 3));
'$e+_sps_init_1'.replaceAll('Exception: ', ''); if (mounted) _navigateTo(const LoginScreen());
});
return; return;
} }
if (!mounted) return; if (!mounted) return;
bool connected = false;
int connectAttempt = 0;
// 3. Навигация в зависимости от результата и статуса аккаунта
if (isLoggedIn) { if (isLoggedIn) {
setState(() => _statusMessage = "Аутентификация...");
bool connected = false;
int connectAttempt = 1;
while (!connected) { while (!connected) {
try { try {
await authProvider.initRealtime(); await authProvider.initRealtime();
connected = true; connected = true;
} catch (e) { } catch (e) {
setState(() => _statusMessage = 'Соединение... (попытка $connectAttempt)');
connectAttempt++; connectAttempt++;
if (e.toString().contains('timeout')) { await Future.delayed(const Duration(seconds: 2));
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)); setState(() => _statusMessage = "Загрузка профиля...");
}
}
await authProvider.refreshMe(); await authProvider.refreshMe();
// Определяем путь пользователя
if (authProvider.needsSetup) { if (authProvider.needsSetup) {
// Путь А: Первичная настройка _navigateTo(const AccountSetupScreen());
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const AccountSetupScreen()),
);
} else if (authProvider.needsKeyRecovery) { } else if (authProvider.needsKeyRecovery) {
// Путь В: Восстановление ключей _navigateTo(const KeyRecoveryScreen());
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const KeyRecoveryScreen()),
);
} else { } else {
// Путь Б: Нормальный вход в контакты setState(() => _statusMessage = "Загрузка контактов...");
// Проверяем, было ли приложение запущено из уведомления _loadContactsAndNavigate(authProvider.currentUserId);
int? targetChatId = }
_targetChatId; // Сначала проверяем из onMessageOpenedApp } else {
if (targetChatId == null) { _navigateTo(const LoginScreen());
final prefs = await SharedPreferences.getInstance(); }
final savedData = prefs.getString(_notificationLaunchKey); }
Future<void> _loadContactsAndNavigate(int? currentUserId) async {
// Navigate to ContactsScreen while contacts are loading in the background
_navigateTo(ContactsScreen(targetChatId: await _getTargetChatId()));
try { try {
final contactProvider = context.read<ContactProvider>(); final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(authProvider.currentUserId); contactProvider.setCurrentUserId(currentUserId);
await contactProvider.loadContacts(enrichContacts: false); await contactProvider.loadContacts(enrichContacts: false);
final myPrivKeyBase64 = await context final prefs = await SharedPreferences.getInstance();
.read<CryptoService>() final myPrivKeyBase64 = await context.read<CryptoService>().getPrivateKey();
.getPrivateKey();
if (myPrivKeyBase64 != null) { if (myPrivKeyBase64 != null) {
final Map<int, String> keysToCompute = {}; final Map<int, String> keysToCompute = {};
for (var c in contactProvider.contacts) { for (var c in contactProvider.contacts) {
final savedKeyHex = prefs.getString( final savedKeyHex = prefs.getString('$_contactSharedKey${c.id}');
'$_contactSharedKey${c.id}', final savedPubKey = prefs.getString('$_contactPublicKey${c.id}');
);
final savedPubKey = prefs.getString(
'$_contactPublicKey${c.id}',
);
if (savedKeyHex != null && savedPubKey == c.publicKey) { if (savedKeyHex != null && savedPubKey == c.publicKey) {
final bytes = base64Decode(savedKeyHex); contactProvider.setSharedKey(c.id, SecretKey(base64Decode(savedKeyHex)));
contactProvider.setSharedKey(c.id, SecretKey(bytes));
} else if (c.publicKey != null) { } else if (c.publicKey != null) {
keysToCompute[c.id] = c.publicKey!; keysToCompute[c.id] = c.publicKey!;
} }
} }
print(
'Contacts with keys for isolate: ${keysToCompute.keys.toList()}',
);
final String privKey = myPrivKeyBase64;
final computedKeys = await compute( final computedKeys = await compute(
CryptoService.computeSharedKeysTask, CryptoService.computeSharedKeysTask,
{'keysMap': keysToCompute, 'privKey': privKey}, {'keysMap': keysToCompute, 'privKey': myPrivKeyBase64},
); );
computedKeys.forEach((id, bytes) { computedKeys.forEach((id, bytes) {
@ -175,160 +162,134 @@ class _SplashScreenState extends State<SplashScreen> {
}); });
} }
} catch (e) { } catch (e) {
print("Ошибка при загрузке контактов или вычислении ключей: $e"); print("Ошибка при фоновой загрузке контактов или ключей: $e");
}
} }
// Если не установлено, проверяем SharedPreferences Future<int?> _getTargetChatId() async {
int? targetChatId = _targetChatId;
final prefs = await SharedPreferences.getInstance();
if (targetChatId == null) {
final savedData = prefs.getString(_notificationLaunchKey);
if (savedData != null) { if (savedData != null) {
try { try {
final data = jsonDecode(savedData) as Map<String, dynamic>; final data = jsonDecode(savedData) as Map<String, dynamic>;
print('Found saved notification data: $data'); targetChatId = int.tryParse(data['sender_id']?.toString() ?? '');
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) { } catch (e) {
print('Error parsing saved notification data: $e'); print('Error parsing saved notification data: $e');
await prefs.remove(_notificationLaunchKey); }
} }
} }
// Также проверяем initialMessage как fallback if (targetChatId == null && initialMessage != null) {
if (targetChatId == null) {
print('Checking initialMessage: $initialMessage');
if (initialMessage != null) {
print('Initial message data: ${initialMessage!.data}');
if (initialMessage!.data['type'] == 'enc_message') { if (initialMessage!.data['type'] == 'enc_message') {
targetChatId = int.tryParse( targetChatId = int.tryParse(initialMessage!.data['sender_id']?.toString() ?? '');
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); await prefs.remove(_notificationLaunchKey);
Navigator.pushReplacement( return targetChatId;
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'); void _navigateTo(Widget screen) {
if (mounted) {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey);
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute( MaterialPageRoute(builder: (_) => screen),
builder: (_) => ContactsScreen(targetChatId: targetChatId),
),
);
}
} else {
// Нет токена - переходим на экран входа
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const LoginScreen()),
); );
} }
} }
@override
void dispose() {
_fadeController.dispose();
_pulseController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeProv = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, body: Stack(
body: Center( 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)),
),
Center(
child: FadeTransition(
opacity: _fadeAnimation,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Spacer(), const Spacer(),
Icon( ScaleTransition(
scale: _pulseAnimation,
child: Icon(
Icons.messenger_outline, Icons.messenger_outline,
size: 80, size: 80,
color: Theme.of(context).colorScheme.primary, color: colorScheme.primary,
),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
"Chepuhagram", "Chepuhagram",
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.primary, color: colorScheme.primary,
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: 1.2, letterSpacing: 1.2,
), ),
), ),
const SizedBox(height: 40), const SizedBox(height: 80),
// Мягкий индикатор загрузки снизу SizedBox(
CircularProgressIndicator( height: 40,
color: Theme.of(context).colorScheme.primary, child: AnimatedSwitcher(
), duration: const Duration(milliseconds: 300),
const SizedBox(height: 40), child: _statusMessage != null
Text( ? Text(
connectError ?? '', _statusMessage!,
key: ValueKey(_statusMessage),
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.error, color: colorScheme.outline,
fontSize: 14, fontSize: 14,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
)
: const SizedBox.shrink(),
), ),
),
const CircularProgressIndicator(),
const Spacer(), const Spacer(),
Text( Text(
'Made by ArturKarasevich', 'Made by ArturKarasevich',
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.primary, color: colorScheme.outline,
fontSize: 12, fontSize: 12,
), ),
), ),
const SizedBox(height: 80), const SizedBox(height: 40),
], ],
), ),
), ),
),
],
),
); );
} }
} }

View File

@ -1,4 +1,6 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '/data/models/message_model.dart'; import '/data/models/message_model.dart';
import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@ -22,6 +24,8 @@ class MessageBubble extends StatefulWidget {
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onReplyTap; final VoidCallback? onReplyTap;
final VoidCallback? onImageTap; final VoidCallback? onImageTap;
final VoidCallback? onEditTap;
final VoidCallback? onDeleteTap;
final Future<void>? Function(MessageModel)? onDownloadRequested; final Future<void>? Function(MessageModel)? onDownloadRequested;
final Future<void>? Function(MessageModel)? onDownloadRequestedWithoutLoad; final Future<void>? Function(MessageModel)? onDownloadRequestedWithoutLoad;
@ -35,6 +39,8 @@ class MessageBubble extends StatefulWidget {
this.onTap, this.onTap,
this.onReplyTap, this.onReplyTap,
this.onImageTap, this.onImageTap,
this.onEditTap,
this.onDeleteTap,
this.onDownloadRequested, this.onDownloadRequested,
this.onDownloadRequestedWithoutLoad, this.onDownloadRequestedWithoutLoad,
this.onDownloadStoped, this.onDownloadStoped,
@ -529,6 +535,103 @@ class _MessageBubbleState extends State<MessageBubble> {
widget.message.localFile!.existsSync(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isMe = widget.message.isMe; final isMe = widget.message.isMe;
@ -550,11 +653,17 @@ class _MessageBubbleState extends State<MessageBubble> {
return Align( return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: GestureDetector(
onSecondaryTapDown: _showContextMenu,
onLongPressStart: (details) {
final tapDownDetails =
TapDownDetails(globalPosition: details.globalPosition);
_showContextMenu(tapDownDetails);
},
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: widget.onTap, onTap: widget.onTap,
onLongPress: widget.onTap,
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16), topLeft: const Radius.circular(16),
topRight: const Radius.circular(16), topRight: const Radius.circular(16),
@ -592,21 +701,13 @@ class _MessageBubbleState extends State<MessageBubble> {
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: _buildMessageBody(primaryTextColor, secondaryTextColor, isLargeScreen), child: _buildMessageBody(primaryTextColor, secondaryTextColor, isLargeScreen),
), ),
if (widget.message.messageType == MessageType.text || if (widget.message.text.isNotEmpty) ...[
widget.message.text.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Linkify( child: SelectableText.rich(
onOpen: (link) async { _buildTextSpan(widget.message.text, primaryTextColor, linkColor, bodyFontSize),
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), style: TextStyle(color: primaryTextColor, fontSize: bodyFontSize),
linkStyle: TextStyle(color: linkColor, fontWeight: FontWeight.bold),
), ),
), ),
], ],
@ -618,6 +719,7 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
), ),
), ),
),
); );
} }
@ -634,6 +736,11 @@ class _MessageBubbleState extends State<MessageBubble> {
case MessageType.voiceNote: case MessageType.voiceNote:
return _buildVoiceNoteBubble(primaryColor, secondaryColor, isLargeScreen); return _buildVoiceNoteBubble(primaryColor, secondaryColor, isLargeScreen);
default: 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(); return const SizedBox.shrink();
} }
} }
@ -1576,7 +1683,8 @@ class _InlineVideoInitErrorFallback extends StatelessWidget {
children: [ children: [
Icon(Icons.play_disabled, color: Colors.white70, size: 40), Icon(Icons.play_disabled, color: Colors.white70, size: 40),
SizedBox(height: 8), 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)),
], ],
), ),
), ),

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