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

View File

@ -577,13 +577,11 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
: const SizedBox.shrink(), : 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: Text( child: Padding(
_currentContact.name.isNotEmpty padding: const EdgeInsets.only(bottom: 1),
? _currentContact.name[0].toUpperCase() child: Text(
: '?', contactInitials,
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,290 +81,254 @@ 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(
onPressed: _selectedContact != null padding: const EdgeInsets.only(right: 8.0),
? () => Navigator.of(context).pop(_selectedContact) child: ElevatedButton(
: null, onPressed: _selectedContact != null
child: const Text( ? () => Navigator.of(context).pop(_selectedContact)
'Продолжить', : null,
style: TextStyle( style: ElevatedButton.styleFrom(
fontSize: 16, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
fontWeight: FontWeight.bold, padding: const EdgeInsets.symmetric(horizontal: 20),
color: Colors.white, backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
), ),
child: const Text('Далее'),
), ),
), ),
), ),
const SizedBox(width: 8),
], ],
), ),
body: () { body: Stack(
if (isLoading) { children: [
return const Center(child: CircularProgressIndicator()); if (themeProv.wallpaperPath != null)
} Container(
decoration: BoxDecoration(
if (contactProvider.error != null) { image: DecorationImage(
return Center( image: FileImage(File(themeProv.wallpaperPath!)),
child: Padding( fit: BoxFit.cover,
padding: const EdgeInsets.all(24.0),
child: Text(
'Ошибка: ${contactProvider.error}',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
),
);
}
if (contacts.isEmpty) {
return const Center(
child: Text(
'Нет активных чатов для пересылки.',
style: TextStyle(color: Colors.grey, fontSize: 15),
),
);
}
return ListView.builder(
itemCount: contacts.length,
itemBuilder: (context, index) {
final contact = contacts[index];
final isSelected = _selectedContact?.id == contact.id;
// Логика формирования текста сообщения (1-в-1 как в твоем ContactTile)
final bool isDecrypted = contact.isLastMsgDecrypted ?? false;
final String subtitleText = isDecrypted
? (contact.lastMessage == null
? "Нет сообщений"
: "${contact.lastMessageType != null ? MessageModel.getMediaPreview(contact.lastMessageType!) : ''} ${contact.lastMessage}"
.trim())
: (contact.lastMessage != null
? "Ожидание дешифровки..."
: "Нет сообщений");
// Логика формирования URL аватарки
final avatarUrl = contact.effectiveAvatarUrl;
final bool hasAvatar = avatarUrl != null && avatarUrl.isNotEmpty;
return InkWell(
onTap: () {
setState(() {
if (isSelected) {
_selectedContact = null;
} else {
_selectedContact = contact;
}
});
},
child: Container(
color: isSelected
? primaryColor.withOpacity(0.08)
: Colors.transparent,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
// 1. АВАТАРКА
leading: Stack(
children: [
if (hasAvatar)
CircleAvatar(
radius: 24,
backgroundColor: Colors.grey[200],
child: ClipOval(
child: ClipOval(
child: Image.network(
avatarUrl, // Первым аргументом идет строка, без "imageUrl:"
width: 48,
height: 48,
fit: BoxFit.cover,
headers: token != null
? {'Authorization': 'Bearer $token'}
: null, // Заменено на headers
// Аналог placeholder
loadingBuilder:
(context, child, loadingProgress) {
if (loadingProgress == null) return child;
return const SizedBox(
width: 48,
height: 48,
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
);
},
// Аналог errorWidget
errorBuilder: (context, error, stackTrace) {
return CircleAvatar(
radius: 24, // 24 радиус = 48 ширина/высота
backgroundColor: primaryColor.withOpacity(
0.1,
),
child: Text(
_getDisplayName(contact).isNotEmpty
? _getDisplayName(
contact,
)[0].toUpperCase()
: '?',
style: TextStyle(
color: primaryColor,
fontWeight: FontWeight.bold,
),
),
);
},
),
),
),
)
else
CircleAvatar(
radius: 24,
backgroundColor: primaryColor.withOpacity(0.1),
child: Text(
_getDisplayName(contact).isNotEmpty
? _getDisplayName(contact)[0].toUpperCase()
: '?',
style: TextStyle(
color: primaryColor,
fontWeight: FontWeight.bold,
),
),
),
if (contact.isOnline == true)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(
context,
).scaffoldBackgroundColor,
width: 2,
),
),
),
),
],
),
// 2. ИМЯ
title: Text(
_getDisplayName(contact),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
// 3. ПОСЛЕДНЕЕ СООБЩЕНИЕ
subtitle: Text(
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey),
),
// 4. ПРАВАЯ ЧАСТЬ (Анимация переключения Время <-> Галочка)
trailing: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder:
(Widget child, Animation<double> animation) {
return ScaleTransition(
scale: animation,
child: child,
);
},
child: isSelected
? Container(
key: const ValueKey('checkmark'),
width: 24,
height: 24,
decoration: BoxDecoration(
color: primaryColor,
shape: BoxShape.circle,
),
child: const Icon(
Icons.check_rounded,
color: Colors.white,
size: 16,
),
)
: Column(
key: const ValueKey('time_and_badge'),
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(contact.lastMessageTime),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
if (contact.unreadCount > 0) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: primaryColor.withAlpha(
(0.5 * 255).round(),
),
shape: BoxShape.circle,
),
child: Text(
'${contact.unreadCount}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
],
],
),
),
), ),
), ),
); ),
}, SafeArea(
); child: () {
}(), if (isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (contactProvider.error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
'Ошибка: ${contactProvider.error}',
textAlign: TextAlign.center,
style: TextStyle(color: colorScheme.outline),
),
),
);
}
if (contacts.isEmpty) {
return Center(
child: Text(
'Нет активных чатов для пересылки.',
style: TextStyle(color: colorScheme.outline, fontSize: 15),
),
);
}
return ListView.builder(
padding: const EdgeInsets.only(top: 8, left: 8, right: 8),
itemCount: contacts.length,
itemBuilder: (context, index) {
final contact = contacts[index];
final isSelected = _selectedContact?.id == contact.id;
final bool isDecrypted = contact.isLastMsgDecrypted;
final String subtitleText = isDecrypted
? (contact.lastMessage == null
? "Нет сообщений"
: "${contact.lastMessageType != null ? MessageModel.getMediaPreview(contact.lastMessageType!) : ''} ${contact.lastMessage}".trim())
: (contact.lastMessage != null
? "Ожидание дешифровки..."
: "Нет сообщений");
final avatarUrl = contact.effectiveAvatarUrl;
final bool hasAvatar = avatarUrl != null && avatarUrl.isNotEmpty;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary.withOpacity(0.2) : colorScheme.surfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected ? colorScheme.primary : colorScheme.outlineVariant.withOpacity(0.2),
width: isSelected ? 2 : 1,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
setState(() {
_selectedContact = isSelected ? null : contact;
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
// Avatar
CircleAvatar(
radius: 26,
backgroundColor: colorScheme.primaryContainer,
child: hasAvatar
? ClipOval(
child: Image.network(
avatarUrl,
fit: BoxFit.cover,
width: 52,
height: 52,
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: Text(
_getInitials(_getDisplayName(contact)),
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: colorScheme.onPrimaryContainer),
),
);
},
errorBuilder: (context, error, stackTrace) {
return Center(
child: Text(
_getInitials(_getDisplayName(contact)),
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: colorScheme.onPrimaryContainer),
),
);
},
),
)
: Center(
child: Text(
_getInitials(_getDisplayName(contact)),
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: colorScheme.onPrimaryContainer),
),
),
),
const SizedBox(width: 12),
// Name and Message
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getDisplayName(contact),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 2),
Text(
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.outline),
),
],
),
),
const SizedBox(width: 12),
// Checkmark
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) => ScaleTransition(scale: animation, child: child),
child: isSelected
? Container(
key: const ValueKey('checkmark'),
width: 28,
height: 28,
decoration: BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle),
child: const Icon(Icons.check_rounded, color: Colors.white, size: 18),
)
:
Text(
_formatTime(contact.lastMessageTime),
key: ValueKey(_formatTime(contact.lastMessageTime)),
textAlign: TextAlign.end,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.outline),
),
),
],
),
),
),
),
),
),
),
);
},
);
}(),
),
],
),
); );
} }
} }

View File

@ -1,13 +1,16 @@
import 'package:chepuhagram/presentation/screens/contacts_screen.dart'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package: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,177 +147,265 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( final themeProv = context.watch<ThemeProvider>();
appBar: AppBar( final colorScheme = Theme.of(context).colorScheme;
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),
// Вариант 1: Начать с чистого листа return Scaffold(
Card( body: Stack(
child: Padding( children: [
padding: const EdgeInsets.all(16.0), if (themeProv.wallpaperPath != null)
child: Column( Container(
crossAxisAlignment: CrossAxisAlignment.start, decoration: BoxDecoration(
children: [ image: DecorationImage(
Row( image: FileImage(File(themeProv.wallpaperPath!)),
children: [ fit: BoxFit.cover,
Icon( ),
Icons.restart_alt_outlined, ),
color: Theme.of(context).colorScheme.primary, ),
size: 28, if (themeProv.wallpaperPath != null)
), BackdropFilter(
const SizedBox(width: 12), filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
Expanded( child: Container(
child: Text( color: Colors.black.withOpacity(0.1),
'Начать с чистого листа', ),
style: Theme.of(context).textTheme.titleMedium?.copyWith( ),
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, 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( const SizedBox(height: 32),
'Создаются новые ключи шифрования. Старые сообщения не будут расшифрованы.',
style: Theme.of(context).textTheme.bodySmall, // Вариант 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( Widget _buildOptionCard({
height: 20, required IconData icon,
width: 20, required String title,
child: CircularProgressIndicator(strokeWidth: 2), required String description,
) required String buttonText,
: const Text('Продолжить'), 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: 12),
), Text(
const SizedBox(height: 24), 'Введите мастер-пароль для восстановления ключей шифрования',
style: TextStyle(color: colorScheme.outline, fontSize: 14),
// Вариант 2: Восстановить из облака ),
Card( const SizedBox(height: 16),
child: Padding( _buildTextField(
padding: const EdgeInsets.all(16.0), controller: _passwordController,
child: Form( label: "Мастер-пароль",
key: _formKey, icon: Icons.lock_outline,
child: Column( obscureText: true,
crossAxisAlignment: CrossAxisAlignment.start, validator: (value) => value!.isEmpty ? "Введите мастер-пароль" : null,
children: [ ),
Row( const SizedBox(height: 20),
children: [ SizedBox(
Icon( width: double.infinity,
Icons.cloud_download_outlined, child: ElevatedButton(
color: Theme.of(context).colorScheme.primary, onPressed: _isLoading ? null : _recoverKeys,
size: 28, style: ElevatedButton.styleFrom(
), padding: const EdgeInsets.symmetric(vertical: 16),
const SizedBox(width: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
Expanded( backgroundColor: colorScheme.primary,
child: Text( foregroundColor: colorScheme.onPrimary,
'Восстановить из облака', ),
style: Theme.of(context).textTheme.titleMedium?.copyWith( child: _isLoading
fontWeight: FontWeight.bold, ? 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: 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: 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;
// Сообщение об ошибке return ClipRRect(
if (_errorMessage != null) borderRadius: BorderRadius.circular(16),
Container( child: Container( // No blur here, it's inside the card
padding: const EdgeInsets.all(12), decoration: BoxDecoration(
decoration: BoxDecoration( color: colorScheme.surface.withOpacity(0.5),
color: Theme.of(context).colorScheme.error.withOpacity(0.1), borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(8), ),
), child: TextFormField(
child: Text( controller: controller,
_errorMessage!, obscureText: obscureText,
style: TextStyle( validator: validator,
color: Theme.of(context).colorScheme.error, decoration: InputDecoration(
), labelText: label,
), labelStyle: TextStyle(color: colorScheme.outline),
), prefixIcon: Icon(icon, color: colorScheme.outline),
], border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
),
), ),
), ),
); );
@ -304,6 +414,7 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
@override @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,124 +23,216 @@ 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(
child: SingleChildScrollView( children: [
padding: const EdgeInsets.all(24.0), // Background Wallpaper
child: Form( if (themeProv.wallpaperPath != null)
key: _formKey, Container(
child: Column( decoration: BoxDecoration(
mainAxisAlignment: MainAxisAlignment.center, image: DecorationImage(
crossAxisAlignment: CrossAxisAlignment.stretch, image: FileImage(File(themeProv.wallpaperPath!)),
children: [ fit: BoxFit.cover,
// Иконка
Icon(
Icons.messenger_outline,
size: 80,
color: Theme.of(context).colorScheme.primary,
), ),
const SizedBox(height: 16), ),
Text( ),
"Чепухаграм", // Blur Overlay
textAlign: TextAlign.center, if (themeProv.wallpaperPath != null)
style: TextStyle( BackdropFilter(
fontSize: 28, filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
fontWeight: FontWeight.bold, child: Container(
color: Theme.of(context).colorScheme.primary, color: Colors.black.withOpacity(0.1),
), ),
), ),
const SizedBox(height: 32),
// Поле Логин Center(
TextFormField( child: SingleChildScrollView(
controller: _usernameController, padding: const EdgeInsets.all(24.0),
decoration: InputDecoration( child: Form(
labelText: "Логин", key: _formKey,
prefixIcon: const Icon(Icons.person_outline), child: Column(
border: OutlineInputBorder( mainAxisAlignment: MainAxisAlignment.center,
borderRadius: BorderRadius.circular(12), crossAxisAlignment: CrossAxisAlignment.stretch,
), children: [
fillColor: Theme.of(context).colorScheme.primary, SlideTransition(
iconColor: Theme.of(context).colorScheme.primary, position: _slideAnimation,
hoverColor: Theme.of(context).colorScheme.primary, child: FadeTransition(
focusColor: Theme.of(context).colorScheme.primary, opacity: _fadeAnimation,
), child: Column(
validator: (value) => value!.isEmpty ? "Введите логин" : null, children: [
), Icon(
const SizedBox(height: 16), Icons.messenger_outline,
size: 80,
// Поле Пароль color: colorScheme.primary,
TextFormField( ),
controller: _passwordController, const SizedBox(height: 16),
obscureText: true, Text(
decoration: InputDecoration( "Чепухаграм",
labelText: "Пароль", textAlign: TextAlign.center,
prefixIcon: const Icon(Icons.lock_outline), style: TextStyle(
border: OutlineInputBorder( fontSize: 28,
borderRadius: BorderRadius.circular(12), 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!.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),
), ),
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, const SizedBox(height: 48),
),
if (_showTotpField) const SizedBox(height: 16),
// Сообщение об ошибке // Поле Логин
if (_errorMessage != null) _buildTextField(
Text( controller: _usernameController,
_errorMessage!, label: "Логин",
style: TextStyle(color: Theme.of(context).colorScheme.error), icon: Icons.person_outline,
textAlign: TextAlign.center, validator: (value) =>
), value!.isEmpty ? "Введите логин" : null,
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),
), ),
), const SizedBox(height: 16),
child: authProvider.isLoading
? const SizedBox( // Поле Пароль
height: 20, _buildTextField(
width: 20, controller: _passwordController,
child: CircularProgressIndicator(strokeWidth: 2), label: "Пароль",
) icon: Icons.lock_outline,
: const Text("Войти", style: TextStyle(fontSize: 16)), obscureText: true,
validator: (value) =>
value!.length < 6 ? "Минимум 6 символов" : null,
),
const SizedBox(height: 16),
// Поле TOTP, если требуется
if (_showTotpField)
_buildTextField(
controller: _totpController,
label: "TOTP код",
icon: Icons.security,
validator: (value) =>
value!.isEmpty ? "Введите TOTP код" : null,
),
if (_showTotpField) const SizedBox(height: 16),
// Сообщение об ошибке
if (_errorMessage != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_errorMessage!,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
),
if (_errorMessage != null) const SizedBox(height: 16),
const SizedBox(height: 24),
// Кнопка Входа
ElevatedButton(
onPressed: authProvider.isLoading ? null : _submit,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
child: authProvider.isLoading
? const SizedBox(
height: 20,
width: 20,
child:
CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text("Войти",
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
),
],
), ),
], ),
),
),
],
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String label,
required IconData icon,
bool obscureText = false,
String? Function(String?)? validator,
}) {
final colorScheme = Theme.of(context).colorScheme;
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.35),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outlineVariant.withOpacity(0.15),
width: 1,
),
),
child: TextFormField(
controller: controller,
obscureText: obscureText,
validator: validator,
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(color: colorScheme.outline),
prefixIcon: Icon(icon, color: colorScheme.outline),
border: InputBorder.none,
contentPadding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
), ),
), ),
), ),
@ -146,9 +241,13 @@ class _LoginScreenState extends State<LoginScreen> {
} }
void _submit() async { void _submit() async {
try { // Сначала убираем фокус с полей, чтобы клавиатура скрылась
if (!_formKey.currentState!.validate()) return; FocusScope.of(context).unfocus();
await Future.delayed(const Duration(milliseconds: 200));
if (!_formKey.currentState!.validate()) return;
try {
final authProvider = context.read<AuthProvider>(); final authProvider = context.read<AuthProvider>();
final success = await authProvider.login( final success = await authProvider.login(
_usernameController.text, _usernameController.text,
@ -157,7 +256,7 @@ class _LoginScreenState extends State<LoginScreen> {
); );
if (success && mounted) { if (success && mounted) {
await authProvider.initRealtime(); await authProvider.initRealtime();
// Определяем путь пользователя после входа // Определяем путь пользователя после входа
if (authProvider.needsSetup) { if (authProvider.needsSetup) {
// Путь А: Первичная настройка // Путь А: Первичная настройка
@ -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,299 +43,252 @@ 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
if (targetChatId == null) {
final prefs = await SharedPreferences.getInstance();
final savedData = prefs.getString(_notificationLaunchKey);
try {
final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(authProvider.currentUserId);
await contactProvider.loadContacts(enrichContacts: false);
final myPrivKeyBase64 = await context
.read<CryptoService>()
.getPrivateKey();
if (myPrivKeyBase64 != null) {
final Map<int, String> keysToCompute = {};
for (var c in contactProvider.contacts) {
final savedKeyHex = prefs.getString(
'$_contactSharedKey${c.id}',
);
final savedPubKey = prefs.getString(
'$_contactPublicKey${c.id}',
);
if (savedKeyHex != null && savedPubKey == c.publicKey) {
final bytes = base64Decode(savedKeyHex);
contactProvider.setSharedKey(c.id, SecretKey(bytes));
} else if (c.publicKey != null) {
keysToCompute[c.id] = c.publicKey!;
}
}
print(
'Contacts with keys for isolate: ${keysToCompute.keys.toList()}',
);
final String privKey = myPrivKeyBase64;
final computedKeys = await compute(
CryptoService.computeSharedKeysTask,
{'keysMap': keysToCompute, 'privKey': privKey},
);
computedKeys.forEach((id, bytes) {
contactProvider.setSharedKey(id, SecretKey(bytes));
prefs.setString('$_contactSharedKey$id', base64Encode(bytes));
prefs.setString('$_contactPublicKey$id', keysToCompute[id]!);
});
}
} catch (e) {
print("Ошибка при загрузке контактов или вычислении ключей: $e");
}
// Если не установлено, проверяем SharedPreferences
if (savedData != null) {
try {
final data = jsonDecode(savedData) as Map<String, dynamic>;
print('Found saved notification data: $data');
final senderId = int.tryParse(
data['sender_id']?.toString() ?? '',
);
final type = data['type']?.toString();
// Поддерживаем старый payload (только sender_id) и новый (type+sender_id)
if (senderId != null && (type == null || type == 'enc_message')) {
targetChatId = senderId;
print(
'App launched from saved notification, target chat: $targetChatId',
);
}
// Очищаем сохраненные данные после использования
await prefs.remove(_notificationLaunchKey);
} catch (e) {
print('Error parsing saved notification data: $e');
await prefs.remove(_notificationLaunchKey);
}
}
// Также проверяем initialMessage как fallback
if (targetChatId == null) {
print('Checking initialMessage: $initialMessage');
if (initialMessage != null) {
print('Initial message data: ${initialMessage!.data}');
if (initialMessage!.data['type'] == 'enc_message') {
targetChatId = int.tryParse(
initialMessage!.data['sender_id']?.toString() ?? '',
);
print('Set target chat from initialMessage: $targetChatId');
} else {
print(
'Initial message type is not enc_message: ${initialMessage!.data['type']}',
);
}
} else {
print('No initial message found');
}
}
} else {
print('Using targetChatId from onMessageOpenedApp: $targetChatId');
}
if (targetChatId != null) {
print(
'Notification targetChatId resolved: $targetChatId, trying to open chat directly',
);
try {
final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(authProvider.currentUserId);
await contactProvider.loadContacts(enrichContacts: false);
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == targetChatId,
);
currentActiveChatContactId = targetChatId;
print(
'Directly navigating to ChatScreen for contact: ${contact.username}',
);
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
);
return;
} catch (e) {
print(
'Failed to open chat directly, falling back to ContactsScreen: $e',
);
}
}
print('Navigating to ContactsScreen, targetChatId: $targetChatId');
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey);
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => ContactsScreen(targetChatId: targetChatId),
),
);
} }
} else { } else {
// Нет токена - переходим на экран входа _navigateTo(const LoginScreen());
}
}
Future<void> _loadContactsAndNavigate(int? currentUserId) async {
// Navigate to ContactsScreen while contacts are loading in the background
_navigateTo(ContactsScreen(targetChatId: await _getTargetChatId()));
try {
final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(currentUserId);
await contactProvider.loadContacts(enrichContacts: false);
final prefs = await SharedPreferences.getInstance();
final myPrivKeyBase64 = await context.read<CryptoService>().getPrivateKey();
if (myPrivKeyBase64 != null) {
final Map<int, String> keysToCompute = {};
for (var c in contactProvider.contacts) {
final savedKeyHex = prefs.getString('$_contactSharedKey${c.id}');
final savedPubKey = prefs.getString('$_contactPublicKey${c.id}');
if (savedKeyHex != null && savedPubKey == c.publicKey) {
contactProvider.setSharedKey(c.id, SecretKey(base64Decode(savedKeyHex)));
} else if (c.publicKey != null) {
keysToCompute[c.id] = c.publicKey!;
}
}
final computedKeys = await compute(
CryptoService.computeSharedKeysTask,
{'keysMap': keysToCompute, 'privKey': myPrivKeyBase64},
);
computedKeys.forEach((id, bytes) {
contactProvider.setSharedKey(id, SecretKey(bytes));
prefs.setString('$_contactSharedKey$id', base64Encode(bytes));
prefs.setString('$_contactPublicKey$id', keysToCompute[id]!);
});
}
} catch (e) {
print("Ошибка при фоновой загрузке контактов или ключей: $e");
}
}
Future<int?> _getTargetChatId() async {
int? targetChatId = _targetChatId;
final prefs = await SharedPreferences.getInstance();
if (targetChatId == null) {
final savedData = prefs.getString(_notificationLaunchKey);
if (savedData != null) {
try {
final data = jsonDecode(savedData) as Map<String, dynamic>;
targetChatId = int.tryParse(data['sender_id']?.toString() ?? '');
} catch (e) {
print('Error parsing saved notification data: $e');
}
}
}
if (targetChatId == null && initialMessage != null) {
if (initialMessage!.data['type'] == 'enc_message') {
targetChatId = int.tryParse(initialMessage!.data['sender_id']?.toString() ?? '');
}
}
await prefs.remove(_notificationLaunchKey);
return targetChatId;
}
void _navigateTo(Widget screen) {
if (mounted) {
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute(builder: (_) => const LoginScreen()), MaterialPageRoute(builder: (_) => screen),
); );
} }
} }
@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: [
child: Column( if (themeProv.wallpaperPath != null)
mainAxisAlignment: MainAxisAlignment.center, Container(
children: [ decoration: BoxDecoration(
const Spacer(), image: DecorationImage(
Icon( image: FileImage(File(themeProv.wallpaperPath!)),
Icons.messenger_outline, fit: BoxFit.cover,
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,
), ),
), ),
const SizedBox(height: 40), if (themeProv.wallpaperPath != null)
// Мягкий индикатор загрузки снизу BackdropFilter(
CircularProgressIndicator( filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
color: Theme.of(context).colorScheme.primary, child: Container(color: Colors.black.withOpacity(0.1)),
), ),
const SizedBox(height: 40),
Text( Center(
connectError ?? '', child: FadeTransition(
style: TextStyle( opacity: _fadeAnimation,
color: Theme.of(context).colorScheme.error, child: Column(
fontSize: 14, mainAxisAlignment: MainAxisAlignment.center,
), children: [
textAlign: TextAlign.center, const Spacer(),
), ScaleTransition(
const Spacer(), scale: _pulseAnimation,
Text( child: Icon(
'Made by ArturKarasevich', Icons.messenger_outline,
style: TextStyle( size: 80,
color: Theme.of(context).colorScheme.primary, color: colorScheme.primary,
fontSize: 12, ),
),
const SizedBox(height: 24),
Text(
"Chepuhagram",
style: TextStyle(
color: colorScheme.primary,
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
const SizedBox(height: 80),
SizedBox(
height: 40,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _statusMessage != null
? Text(
_statusMessage!,
key: ValueKey(_statusMessage),
style: TextStyle(
color: colorScheme.outline,
fontSize: 14,
),
textAlign: TextAlign.center,
)
: const SizedBox.shrink(),
),
),
const CircularProgressIndicator(),
const Spacer(),
Text(
'Made by ArturKarasevich',
style: TextStyle(
color: colorScheme.outline,
fontSize: 12,
),
),
const SizedBox(height: 40),
],
), ),
), ),
const SizedBox(height: 80), ),
], ],
),
), ),
); );
} }

View File

@ -1,4 +1,6 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/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,69 +653,68 @@ class _MessageBubbleState extends State<MessageBubble> {
return Align( return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Material( child: GestureDetector(
color: Colors.transparent, onSecondaryTapDown: _showContextMenu,
child: InkWell( onLongPressStart: (details) {
onTap: widget.onTap, final tapDownDetails =
onLongPress: widget.onTap, TapDownDetails(globalPosition: details.globalPosition);
borderRadius: BorderRadius.only( _showContextMenu(tapDownDetails);
topLeft: const Radius.circular(16), },
topRight: const Radius.circular(16), child: Material(
bottomLeft: Radius.circular(isMe ? 16 : 0), color: Colors.transparent,
bottomRight: Radius.circular(isMe ? 0 : 16), child: InkWell(
), onTap: widget.onTap,
child: Container( borderRadius: BorderRadius.only(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), topLeft: const Radius.circular(16),
padding: EdgeInsets.symmetric(vertical: paddingVertical, horizontal: paddingHorizontal), topRight: const Radius.circular(16),
constraints: BoxConstraints( bottomLeft: Radius.circular(isMe ? 16 : 0),
// Максимальная ширина на больших мониторах ограничена 460px (как в Telegram Desktop) bottomRight: Radius.circular(isMe ? 0 : 16),
maxWidth: math.min(screenWidth * 0.75, 460.0),
), ),
decoration: BoxDecoration( child: Container(
color: isMe margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
? Theme.of(context).colorScheme.brightness == Brightness.dark padding: EdgeInsets.symmetric(vertical: paddingVertical, horizontal: paddingHorizontal),
? Theme.of(context).colorScheme.primaryContainer constraints: BoxConstraints(
: Theme.of(context).colorScheme.primary // Максимальная ширина на больших мониторах ограничена 460px (как в Telegram Desktop)
: Colors.grey[800], maxWidth: math.min(screenWidth * 0.75, 460.0),
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(
child: IntrinsicWidth( color: isMe
child: Column( ? Theme.of(context).colorScheme.brightness == Brightness.dark
crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, ? Theme.of(context).colorScheme.primaryContainer
children: [ : Theme.of(context).colorScheme.primary
if (widget.message.replyToText != null) ...[ : Colors.grey[800],
_buildReplyWidget(isMe, secondaryTextColor, replyFontSize), borderRadius: BorderRadius.only(
], topLeft: const Radius.circular(16),
Align( topRight: const Radius.circular(16),
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, bottomLeft: Radius.circular(isMe ? 16 : 0),
child: _buildMessageBody(primaryTextColor, secondaryTextColor, isLargeScreen), bottomRight: Radius.circular(isMe ? 0 : 16),
), ),
if (widget.message.messageType == MessageType.text || ),
widget.message.text.isNotEmpty) ...[ child: IntrinsicWidth(
const SizedBox(height: 4), child: Column(
crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
if (widget.message.replyToText != null) ...[
_buildReplyWidget(isMe, secondaryTextColor, replyFontSize),
],
Align( Align(
alignment: Alignment.centerLeft, alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Linkify( child: _buildMessageBody(primaryTextColor, secondaryTextColor, isLargeScreen),
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),
),
), ),
if (widget.message.text.isNotEmpty) ...[
const SizedBox(height: 4),
Align(
alignment: Alignment.centerLeft,
child: SelectableText.rich(
_buildTextSpan(widget.message.text, primaryTextColor, linkColor, bodyFontSize),
style: TextStyle(color: primaryTextColor, fontSize: bodyFontSize),
),
),
],
const SizedBox(height: 4),
_buildTimeAndStatusRow(isMe, secondaryTextColor, timeFontSize),
], ],
const SizedBox(height: 4), ),
_buildTimeAndStatusRow(isMe, secondaryTextColor, timeFontSize),
],
), ),
), ),
), ),
@ -634,6 +736,11 @@ class _MessageBubbleState extends State<MessageBubble> {
case MessageType.voiceNote: 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)),
], ],
), ),
), ),
@ -1876,4 +1984,4 @@ class _InlineVoiceNotePlayerState extends State<InlineVoiceNotePlayer> {
), ),
); );
} }
} }

View File

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB