diff --git a/lib/logic/auth_provider.dart b/lib/logic/auth_provider.dart index b373865..ce5f659 100644 --- a/lib/logic/auth_provider.dart +++ b/lib/logic/auth_provider.dart @@ -231,11 +231,11 @@ class AuthProvider extends ChangeNotifier { return true; } - Future updateProfileAndSecurity({ - required String firstName, + Future setupAccount( + String firstName, String? lastName, - required String masterPassword, - }) async { + String masterPassword, + ) async { notifyListeners(); try { diff --git a/lib/presentation/screens/account_setup_screen.dart b/lib/presentation/screens/account_setup_screen.dart index c56c521..93f6894 100644 --- a/lib/presentation/screens/account_setup_screen.dart +++ b/lib/presentation/screens/account_setup_screen.dart @@ -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 createState() => _AccountSetupScreenState(); } -class _AccountSetupScreenState extends State { +class _AccountSetupScreenState extends State + with SingleTickerProviderStateMixin { final _formKey = GlobalKey(); 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 _fadeAnimation; + late Animation _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 _setupAccount() async { - if (!_formKey.currentState!.validate()) return; + _fadeAnimation = + Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeIn, + )); - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _slideAnimation = + Tween(begin: const Offset(0, 0.3), end: Offset.zero).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.fastOutSlowIn, + )); - try { - final authProvider = context.read(); - - // Отправляем данные на сервер с мастер-паролем - 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(); + final themeProv = context.watch(); + 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(); + 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(); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/chat_screen.dart b/lib/presentation/screens/chat_screen.dart index 6236ad2..b5127ba 100644 --- a/lib/presentation/screens/chat_screen.dart +++ b/lib/presentation/screens/chat_screen.dart @@ -577,13 +577,11 @@ class _ChatScreenState extends State with RouteAware { : const SizedBox.shrink(), title: Consumer( 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 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 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 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, + ), ), ), ) diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 69e51b3..7986cc9 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -337,7 +337,7 @@ class _ContactsScreenState extends State 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 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 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 with RouteAware { if (didPop) return; if (_selectedContact != null && isPhoneFormFactor) { - _clearSelectedContact(); // Плавно закрываем чат и возвращаемся к списку + _clearSelectedContact(); } }, child: _buildResponsiveBody(isPhoneFormFactor), diff --git a/lib/presentation/screens/forward_contact_picker_screen.dart b/lib/presentation/screens/forward_contact_picker_screen.dart index 93d368f..e9feef8 100644 --- a/lib/presentation/screens/forward_contact_picker_screen.dart +++ b/lib/presentation/screens/forward_contact_picker_screen.dart @@ -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(); final contacts = contactProvider.contacts; final isLoading = _isInitLoading || contactProvider.isLoading; - final primaryColor = Theme.of(context).colorScheme.primary; + final themeProv = context.watch(); + 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 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), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + }, + ); + }(), + ), + ], + ), ); } } diff --git a/lib/presentation/screens/key_recovery_screen.dart b/lib/presentation/screens/key_recovery_screen.dart index 5f0c46a..e421392 100644 --- a/lib/presentation/screens/key_recovery_screen.dart +++ b/lib/presentation/screens/key_recovery_screen.dart @@ -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 createState() => _KeyRecoveryScreenState(); } -class _KeyRecoveryScreenState extends State { +class _KeyRecoveryScreenState extends State with SingleTickerProviderStateMixin { bool _isLoading = false; String? _errorMessage; final _passwordController = TextEditingController(); final _formKey = GlobalKey(); + late AnimationController _animationController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + + _fadeAnimation = + Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeIn, + )); + + _slideAnimation = + Tween(begin: const Offset(0, 0.3), end: Offset.zero).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.fastOutSlowIn, + )); + + _animationController.forward(); + } + Future _startFresh() async { setState(() { _isLoading = true; @@ -31,19 +62,15 @@ class _KeyRecoveryScreenState extends State { try { final authProvider = context.read(); - // Удаляем все сообщения пользователя 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 { Future _recoverKeys() async { if (!_formKey.currentState!.validate()) return; + FocusScope.of(context).unfocus(); setState(() { _isLoading = true; @@ -73,11 +101,9 @@ class _KeyRecoveryScreenState extends State { 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 { 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 { @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(); + 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 { @override void dispose() { _passwordController.dispose(); + _animationController.dispose(); super.dispose(); } } diff --git a/lib/presentation/screens/login_screen.dart b/lib/presentation/screens/login_screen.dart index fc7e621..3a36988 100644 --- a/lib/presentation/screens/login_screen.dart +++ b/lib/presentation/screens/login_screen.dart @@ -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 createState() => _LoginScreenState(); + State createState() => _LoginScreenState(); } -class _LoginScreenState extends State { +class _LoginScreenState extends State + with SingleTickerProviderStateMixin { final _formKey = GlobalKey(); final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); @@ -20,124 +23,216 @@ class _LoginScreenState extends State { bool _showTotpField = false; String? _errorMessage; - @override + late AnimationController _animationController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + + + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + + _fadeAnimation = + Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeIn, + )); + + _slideAnimation = + Tween(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(); + final themeProv = context.watch(); + 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 { } 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(); final success = await authProvider.login( _usernameController.text, @@ -157,7 +256,7 @@ class _LoginScreenState extends State { ); if (success && mounted) { await authProvider.initRealtime(); - + // Определяем путь пользователя после входа if (authProvider.needsSetup) { // Путь А: Первичная настройка @@ -181,24 +280,23 @@ class _LoginScreenState extends State { } } 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(); } -} +} \ No newline at end of file diff --git a/lib/presentation/screens/my_profile_screen.dart b/lib/presentation/screens/my_profile_screen.dart index 809bb67..6f26b93 100644 --- a/lib/presentation/screens/my_profile_screen.dart +++ b/lib/presentation/screens/my_profile_screen.dart @@ -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 { 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 { Expanded( child: _buildActionButton( icon: Icons.photo_camera_rounded, - label: 'Фото', + label: 'Поставить фото', onTap: _pickAvatar, ), ), diff --git a/lib/presentation/screens/splash_screen.dart b/lib/presentation/screens/splash_screen.dart index 403dea4..6879bef 100644 --- a/lib/presentation/screens/splash_screen.dart +++ b/lib/presentation/screens/splash_screen.dart @@ -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 createState() => _SplashScreenState(); } -class _SplashScreenState extends State { +class _SplashScreenState extends State + with TickerProviderStateMixin { int? _targetChatId; - String? connectError; + String? _statusMessage; + + late AnimationController _fadeController; + late AnimationController _pulseController; + late Animation _fadeAnimation; + late Animation _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 { @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(begin: 0.0, end: 1.0).animate(CurvedAnimation( + parent: _fadeController, + curve: Curves.easeIn, + )); + + _pulseAnimation = Tween(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 _initializeApp() async { - // 1. Искусственная задержка в 2 секунды для демонстрации splash - await Future.delayed(const Duration(seconds: 2)); - if (!mounted) return; + setState(() => _statusMessage = "Подключение..."); - // 2. Пытаемся выполнить автологин final authProvider = context.read(); 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.setCurrentUserId(authProvider.currentUserId); - - await contactProvider.loadContacts(enrichContacts: false); - - final myPrivKeyBase64 = await context - .read() - .getPrivateKey(); - - if (myPrivKeyBase64 != null) { - final Map 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; - 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.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 _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.setCurrentUserId(currentUserId); + await contactProvider.loadContacts(enrichContacts: false); + + final prefs = await SharedPreferences.getInstance(); + final myPrivKeyBase64 = await context.read().getPrivateKey(); + + if (myPrivKeyBase64 != null) { + final Map 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 _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; + 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(); + 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), - ], - ), + ), + ], ), ); } diff --git a/lib/presentation/widgets/message_bubble.dart b/lib/presentation/widgets/message_bubble.dart index 6ac53c4..3ccf2dd 100644 --- a/lib/presentation/widgets/message_bubble.dart +++ b/lib/presentation/widgets/message_bubble.dart @@ -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? Function(MessageModel)? onDownloadRequested; final Future? 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 { 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( + context: context, + position: position, + items: [ + const PopupMenuItem( + value: 'reply', + child: Text('Reply'), + ), + if (widget.message.text.isNotEmpty) + const PopupMenuItem( + value: 'copy', + child: Text('Copy Text'), + ), + if (widget.message.isMe) + const PopupMenuItem( + value: 'edit', + child: Text('Edit'), + ), + if (widget.message.isMe) + const PopupMenuItem( + 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 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 { 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 { 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 { ), ); } -} \ No newline at end of file +} diff --git a/srv/cloud_media_cache/33524fb9e6704a0e86d6fd9b3311fa06.enc b/srv/cloud_media_cache/33524fb9e6704a0e86d6fd9b3311fa06.png similarity index 100% rename from srv/cloud_media_cache/33524fb9e6704a0e86d6fd9b3311fa06.enc rename to srv/cloud_media_cache/33524fb9e6704a0e86d6fd9b3311fa06.png diff --git a/srv/cloud_media_cache/629ec080961b4728961038f9d84de5cb.enc b/srv/cloud_media_cache/629ec080961b4728961038f9d84de5cb.png similarity index 100% rename from srv/cloud_media_cache/629ec080961b4728961038f9d84de5cb.enc rename to srv/cloud_media_cache/629ec080961b4728961038f9d84de5cb.png diff --git a/srv/cloud_media_cache/67da7307265349ca856644bbbe0331a1.enc b/srv/cloud_media_cache/67da7307265349ca856644bbbe0331a1.png similarity index 100% rename from srv/cloud_media_cache/67da7307265349ca856644bbbe0331a1.enc rename to srv/cloud_media_cache/67da7307265349ca856644bbbe0331a1.png diff --git a/srv/cloud_media_cache/cfcd4e2fafeb4e579547f15205e0bd33.enc b/srv/cloud_media_cache/cfcd4e2fafeb4e579547f15205e0bd33.png similarity index 100% rename from srv/cloud_media_cache/cfcd4e2fafeb4e579547f15205e0bd33.enc rename to srv/cloud_media_cache/cfcd4e2fafeb4e579547f15205e0bd33.png diff --git a/srv/cloud_media_cache/fc0927b8b9b74ea3884b67d55b2fd4c7.enc b/srv/cloud_media_cache/fc0927b8b9b74ea3884b67d55b2fd4c7.png similarity index 100% rename from srv/cloud_media_cache/fc0927b8b9b74ea3884b67d55b2fd4c7.enc rename to srv/cloud_media_cache/fc0927b8b9b74ea3884b67d55b2fd4c7.png