04-06-26+16-27
This commit is contained in:
parent
11340bdca1
commit
e9b025a34d
|
|
@ -231,11 +231,11 @@ class AuthProvider extends ChangeNotifier {
|
|||
return true;
|
||||
}
|
||||
|
||||
Future<bool> updateProfileAndSecurity({
|
||||
required String firstName,
|
||||
Future<bool> setupAccount(
|
||||
String firstName,
|
||||
String? lastName,
|
||||
required String masterPassword,
|
||||
}) async {
|
||||
String masterPassword,
|
||||
) async {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../logic/auth_provider.dart';
|
||||
import 'contacts_screen.dart';
|
||||
import 'package:chepuhagram/logic/auth_provider.dart';
|
||||
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
|
||||
import 'package:chepuhagram/core/theme_manager.dart';
|
||||
import 'dart:io';
|
||||
|
||||
class AccountSetupScreen extends StatefulWidget {
|
||||
const AccountSetupScreen({super.key});
|
||||
|
|
@ -10,225 +13,276 @@ class AccountSetupScreen extends StatefulWidget {
|
|||
State<AccountSetupScreen> createState() => _AccountSetupScreenState();
|
||||
}
|
||||
|
||||
class _AccountSetupScreenState extends State<AccountSetupScreen> {
|
||||
class _AccountSetupScreenState extends State<AccountSetupScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _firstNameController = TextEditingController();
|
||||
final _lastNameController = TextEditingController();
|
||||
final _masterPasswordController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_masterPasswordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
);
|
||||
|
||||
Future<void> _setupAccount() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
_fadeAnimation =
|
||||
Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeIn,
|
||||
));
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_slideAnimation =
|
||||
Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
));
|
||||
|
||||
try {
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
|
||||
// Отправляем данные на сервер с мастер-паролем
|
||||
final success = await authProvider.updateProfileAndSecurity(
|
||||
firstName: _firstNameController.text.trim(),
|
||||
lastName: _lastNameController.text.trim(),
|
||||
masterPassword: _masterPasswordController.text,
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
// Переходим на экран контактов
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||
);
|
||||
} else if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = 'Ошибка при сохранении профиля. Попробуйте еще раз.';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = 'Ошибка: ${e.toString()}';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authProvider = context.watch<AuthProvider>();
|
||||
final themeProv = context.watch<ThemeProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Завершение настройки'),
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Завершите настройку вашего профиля',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Введите ваше имя, фамилию и создайте мастер-пароль. Мастер-пароль будет использоваться для защиты ваших ключей шифрования.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Поле Имя
|
||||
TextFormField(
|
||||
controller: _firstNameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Имя *',
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
hintText: 'Введите ваше имя',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Имя не может быть пустым';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Поле Фамилия
|
||||
TextFormField(
|
||||
controller: _lastNameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Фамилия',
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
hintText: 'Введите вашу фамилию (опционально)',
|
||||
body: Stack(
|
||||
children: [
|
||||
// Background Wallpaper
|
||||
if (themeProv.wallpaperPath != null)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: FileImage(File(themeProv.wallpaperPath!)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Поле Мастер-пароль
|
||||
TextFormField(
|
||||
controller: _masterPasswordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Мастер-пароль *',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
hintText: 'Создайте надежный пароль',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Мастер-пароль не может быть пустым';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Пароль должен содержать минимум 8 символов';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
// Blur Overlay
|
||||
if (themeProv.wallpaperPath != null)
|
||||
BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
),
|
||||
|
||||
// Поле Подтверждение пароля
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Подтвердите пароль *',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
hintText: 'Повторите пароль',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Подтвердите пароль';
|
||||
}
|
||||
if (value != _masterPasswordController.text) {
|
||||
return 'Пароли не совпадают';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Сообщение об ошибке
|
||||
if (_errorMessage != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Кнопка подтверждения
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _setupAccount,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text(
|
||||
'Завершить настройку',
|
||||
style: TextStyle(fontSize: 16),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person_add_alt_1_outlined,
|
||||
size: 80,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"Настройка аккаунта",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
"Укажите ваше имя и создайте мастер-пароль",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Сохраните мастер-пароль в надежном месте. Он потребуется для восстановления ключей шифрования при переустановке приложения.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
// Поле Имя
|
||||
_buildTextField(
|
||||
controller: _firstNameController,
|
||||
label: "Имя",
|
||||
icon: Icons.person_outline,
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? "Введите ваше имя" : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Поле Фамилия
|
||||
_buildTextField(
|
||||
controller: _lastNameController,
|
||||
label: "Фамилия",
|
||||
icon: Icons.person_outline,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Поле Мастер-пароль
|
||||
_buildTextField(
|
||||
controller: _passwordController,
|
||||
label: "Мастер-пароль",
|
||||
icon: Icons.lock_outline,
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Введите мастер-пароль';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Пароль должен быть не менее 8 символов';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Поле Подтверждение пароля
|
||||
_buildTextField(
|
||||
controller: _confirmPasswordController,
|
||||
label: "Подтвердите пароль",
|
||||
icon: Icons.lock_person_outlined,
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value != _passwordController.text) {
|
||||
return 'Пароли не совпадают';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Кнопка Продолжить
|
||||
ElevatedButton(
|
||||
onPressed: authProvider.isLoading ? null : _submit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
),
|
||||
child: authProvider.isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text("Создать аккаунт",
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required IconData icon,
|
||||
String? Function(String?)? validator,
|
||||
bool obscureText = false,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.35),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.15),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
validator: validator,
|
||||
obscureText: obscureText,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: TextStyle(color: colorScheme.outline),
|
||||
prefixIcon: Icon(icon, color: colorScheme.outline),
|
||||
border: InputBorder.none,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _submit() async {
|
||||
FocusScope.of(context).unfocus();
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
try {
|
||||
await authProvider.setupAccount(
|
||||
_firstNameController.text.trim(),
|
||||
_lastNameController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -577,13 +577,11 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
|||
: const SizedBox.shrink(),
|
||||
title: Consumer<ContactProvider>(
|
||||
builder: (context, contactProvider, child) {
|
||||
// Реактивно отслеживаем изменения пользователя в провайдере (например, аватарку)
|
||||
final freshContact = contactProvider.contacts.firstWhere(
|
||||
(c) => c.id == widget.contact.id,
|
||||
orElse: () => widget.contact,
|
||||
);
|
||||
|
||||
// ФИКС ОНЛАЙНА: Определяем статус на основе встроенного таймера экрана чата
|
||||
final bool currentOnline = freshContact.isOnline || _isOnline;
|
||||
final String subtitleText = currentOnline
|
||||
? 'в сети'
|
||||
|
|
@ -605,6 +603,18 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
|||
lName = '';
|
||||
}
|
||||
|
||||
final String localFullName = '${_currentContact.name} ${_currentContact.surname}'
|
||||
.trim();
|
||||
|
||||
final contactInitials = localFullName.isNotEmpty
|
||||
? localFullName
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.take(2)
|
||||
.map((e) => e[0].toUpperCase())
|
||||
.join()
|
||||
: '?';
|
||||
|
||||
final String cleanFullName = '$fName $lName'.trim();
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
|
|
@ -635,9 +645,7 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
|||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withOpacity(0.4),
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
image: freshContact.avatarUrl != null
|
||||
? DecorationImage(
|
||||
image: NetworkImage(freshContact.avatarUrl!),
|
||||
|
|
@ -647,14 +655,17 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
|||
),
|
||||
child: freshContact.avatarUrl == null
|
||||
? Center(
|
||||
child: Text(
|
||||
_currentContact.name.isNotEmpty
|
||||
? _currentContact.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 1),
|
||||
child: Text(
|
||||
contactInitials,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -337,7 +337,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
final localName = _localFullNames[contact.id];
|
||||
final displayName = (localName != null && localName.isNotEmpty)
|
||||
? localName
|
||||
: contact.name;
|
||||
: '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim();
|
||||
|
||||
final contactInitials = displayName.isNotEmpty
|
||||
? displayName
|
||||
|
|
@ -402,7 +402,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.primary.withOpacity(0.08),
|
||||
color: colorScheme.primaryContainer,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Stack(
|
||||
|
|
@ -411,9 +411,9 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
Text(
|
||||
contactInitials,
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
if (contact.avatarUrl != null)
|
||||
|
|
@ -1020,7 +1020,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
if (didPop) return;
|
||||
|
||||
if (_selectedContact != null && isPhoneFormFactor) {
|
||||
_clearSelectedContact(); // Плавно закрываем чат и возвращаемся к списку
|
||||
_clearSelectedContact();
|
||||
}
|
||||
},
|
||||
child: _buildResponsiveBody(isPhoneFormFactor),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '/core/constants.dart';
|
||||
import 'dart:ui';
|
||||
import 'dart:io';
|
||||
import '/data/models/message_model.dart';
|
||||
import '/data/models/contact_model.dart';
|
||||
import '/logic/contact_provider.dart';
|
||||
import '/domain/services/api_service.dart';
|
||||
import '/core/theme_manager.dart';
|
||||
|
||||
class ForwardContactPickerScreen extends StatefulWidget {
|
||||
final MessageModel message;
|
||||
|
|
@ -57,13 +59,18 @@ class _ForwardContactPickerScreenState
|
|||
}
|
||||
|
||||
String _getDisplayName(Contact contact) {
|
||||
if (_prefs == null) return contact.name;
|
||||
if (_prefs == null) return '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim();
|
||||
final id = contact.id;
|
||||
final savedName = _prefs!.getString('firstname_$id');
|
||||
final savedSurname = _prefs!.getString('lastname_$id');
|
||||
String? displayName;
|
||||
if (savedName != null && savedName.isNotEmpty) {
|
||||
return savedName;
|
||||
displayName = savedName;
|
||||
}
|
||||
return contact.name;
|
||||
if (savedSurname != null && savedSurname.isNotEmpty) {
|
||||
(displayName == null || displayName.isEmpty) ? displayName = savedSurname : displayName += " $savedSurname";
|
||||
}
|
||||
return displayName ?? '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim();
|
||||
}
|
||||
|
||||
String _formatTime(DateTime? time) {
|
||||
|
|
@ -74,290 +81,254 @@ class _ForwardContactPickerScreenState
|
|||
return '$hour:$minute';
|
||||
}
|
||||
|
||||
String _getInitials(String name) {
|
||||
if (name.isEmpty) return '?';
|
||||
final names = name.trim().split(RegExp(r'\s+')).where((s) => s.isNotEmpty).toList();
|
||||
if (names.length > 1) {
|
||||
return (names[0][0] + names[1][0]).toUpperCase();
|
||||
} else if (names.isNotEmpty) {
|
||||
return names[0][0].toUpperCase();
|
||||
}
|
||||
return '?';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final contactProvider = context.watch<ContactProvider>();
|
||||
final contacts = contactProvider.contacts;
|
||||
final isLoading = _isInitLoading || contactProvider.isLoading;
|
||||
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||
final themeProv = context.watch<ThemeProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: colorScheme.surface.withOpacity(0.85),
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(bottom: Radius.circular(24)),
|
||||
),
|
||||
flexibleSpace: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(24)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'Переслать...',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
actions: [
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _selectedContact != null ? 1.0 : 0.4,
|
||||
child: TextButton(
|
||||
onPressed: _selectedContact != null
|
||||
? () => Navigator.of(context).pop(_selectedContact)
|
||||
: null,
|
||||
child: const Text(
|
||||
'Продолжить',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
opacity: _selectedContact != null ? 1.0 : 0.5,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: _selectedContact != null
|
||||
? () => Navigator.of(context).pop(_selectedContact)
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
),
|
||||
child: const Text('Далее'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: () {
|
||||
if (isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (contactProvider.error != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
'Ошибка: ${contactProvider.error}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (contacts.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Нет активных чатов для пересылки.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 15),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: contacts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = contacts[index];
|
||||
final isSelected = _selectedContact?.id == contact.id;
|
||||
|
||||
// Логика формирования текста сообщения (1-в-1 как в твоем ContactTile)
|
||||
final bool isDecrypted = contact.isLastMsgDecrypted ?? false;
|
||||
final String subtitleText = isDecrypted
|
||||
? (contact.lastMessage == null
|
||||
? "Нет сообщений"
|
||||
: "${contact.lastMessageType != null ? MessageModel.getMediaPreview(contact.lastMessageType!) : ''} ${contact.lastMessage}"
|
||||
.trim())
|
||||
: (contact.lastMessage != null
|
||||
? "Ожидание дешифровки..."
|
||||
: "Нет сообщений");
|
||||
|
||||
// Логика формирования URL аватарки
|
||||
final avatarUrl = contact.effectiveAvatarUrl;
|
||||
final bool hasAvatar = avatarUrl != null && avatarUrl.isNotEmpty;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (isSelected) {
|
||||
_selectedContact = null;
|
||||
} else {
|
||||
_selectedContact = contact;
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
color: isSelected
|
||||
? primaryColor.withOpacity(0.08)
|
||||
: Colors.transparent,
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
|
||||
// 1. АВАТАРКА
|
||||
leading: Stack(
|
||||
children: [
|
||||
if (hasAvatar)
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: Colors.grey[200],
|
||||
child: ClipOval(
|
||||
child: ClipOval(
|
||||
child: Image.network(
|
||||
avatarUrl, // Первым аргументом идет строка, без "imageUrl:"
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
headers: token != null
|
||||
? {'Authorization': 'Bearer $token'}
|
||||
: null, // Заменено на headers
|
||||
// Аналог placeholder
|
||||
loadingBuilder:
|
||||
(context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return const SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
// Аналог errorWidget
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return CircleAvatar(
|
||||
radius: 24, // 24 радиус = 48 ширина/высота
|
||||
backgroundColor: primaryColor.withOpacity(
|
||||
0.1,
|
||||
),
|
||||
child: Text(
|
||||
_getDisplayName(contact).isNotEmpty
|
||||
? _getDisplayName(
|
||||
contact,
|
||||
)[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: primaryColor.withOpacity(0.1),
|
||||
child: Text(
|
||||
_getDisplayName(contact).isNotEmpty
|
||||
? _getDisplayName(contact)[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (contact.isOnline == true)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).scaffoldBackgroundColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 2. ИМЯ
|
||||
title: Text(
|
||||
_getDisplayName(contact),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
|
||||
// 3. ПОСЛЕДНЕЕ СООБЩЕНИЕ
|
||||
subtitle: Text(
|
||||
subtitleText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
|
||||
// 4. ПРАВАЯ ЧАСТЬ (Анимация переключения Время <-> Галочка)
|
||||
trailing: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
transitionBuilder:
|
||||
(Widget child, Animation<double> animation) {
|
||||
return ScaleTransition(
|
||||
scale: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: isSelected
|
||||
? Container(
|
||||
key: const ValueKey('checkmark'),
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: primaryColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check_rounded,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
key: const ValueKey('time_and_badge'),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(contact.lastMessageTime),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (contact.unreadCount > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: primaryColor.withAlpha(
|
||||
(0.5 * 255).round(),
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'${contact.unreadCount}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
if (themeProv.wallpaperPath != null)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: FileImage(File(themeProv.wallpaperPath!)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}(),
|
||||
),
|
||||
SafeArea(
|
||||
child: () {
|
||||
if (isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (contactProvider.error != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
'Ошибка: ${contactProvider.error}',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (contacts.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Нет активных чатов для пересылки.',
|
||||
style: TextStyle(color: colorScheme.outline, fontSize: 15),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 8, left: 8, right: 8),
|
||||
itemCount: contacts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = contacts[index];
|
||||
final isSelected = _selectedContact?.id == contact.id;
|
||||
|
||||
final bool isDecrypted = contact.isLastMsgDecrypted;
|
||||
final String subtitleText = isDecrypted
|
||||
? (contact.lastMessage == null
|
||||
? "Нет сообщений"
|
||||
: "${contact.lastMessageType != null ? MessageModel.getMediaPreview(contact.lastMessageType!) : ''} ${contact.lastMessage}".trim())
|
||||
: (contact.lastMessage != null
|
||||
? "Ожидание дешифровки..."
|
||||
: "Нет сообщений");
|
||||
|
||||
final avatarUrl = contact.effectiveAvatarUrl;
|
||||
final bool hasAvatar = avatarUrl != null && avatarUrl.isNotEmpty;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primary.withOpacity(0.2) : colorScheme.surfaceVariant.withOpacity(0.4),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected ? colorScheme.primary : colorScheme.outlineVariant.withOpacity(0.2),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedContact = isSelected ? null : contact;
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar
|
||||
CircleAvatar(
|
||||
radius: 26,
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: hasAvatar
|
||||
? ClipOval(
|
||||
child: Image.network(
|
||||
avatarUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: 52,
|
||||
height: 52,
|
||||
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: Text(
|
||||
_getInitials(_getDisplayName(contact)),
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: colorScheme.onPrimaryContainer),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Center(
|
||||
child: Text(
|
||||
_getInitials(_getDisplayName(contact)),
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: colorScheme.onPrimaryContainer),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Text(
|
||||
_getInitials(_getDisplayName(contact)),
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: colorScheme.onPrimaryContainer),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Name and Message
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_getDisplayName(contact),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitleText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Checkmark
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
transitionBuilder: (child, animation) => ScaleTransition(scale: animation, child: child),
|
||||
child: isSelected
|
||||
? Container(
|
||||
key: const ValueKey('checkmark'),
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle),
|
||||
child: const Icon(Icons.check_rounded, color: Colors.white, size: 18),
|
||||
)
|
||||
:
|
||||
Text(
|
||||
_formatTime(contact.lastMessageTime),
|
||||
key: ValueKey(_formatTime(contact.lastMessageTime)),
|
||||
textAlign: TextAlign.end,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../logic/auth_provider.dart';
|
||||
import '../../domain/services/crypto_service.dart';
|
||||
import '../../domain/services/api_service.dart';
|
||||
import 'account_setup_screen.dart';
|
||||
import 'package:chepuhagram/logic/auth_provider.dart';
|
||||
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
|
||||
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
||||
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||
import 'package:chepuhagram/presentation/screens/account_setup_screen.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../core/constants.dart';
|
||||
import 'package:chepuhagram/core/constants.dart';
|
||||
import 'package:chepuhagram/core/theme_manager.dart';
|
||||
import 'dart:io';
|
||||
|
||||
class KeyRecoveryScreen extends StatefulWidget {
|
||||
const KeyRecoveryScreen({super.key});
|
||||
|
|
@ -16,12 +19,40 @@ class KeyRecoveryScreen extends StatefulWidget {
|
|||
State<KeyRecoveryScreen> createState() => _KeyRecoveryScreenState();
|
||||
}
|
||||
|
||||
class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
|
||||
class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> with SingleTickerProviderStateMixin {
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
final _passwordController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
);
|
||||
|
||||
_fadeAnimation =
|
||||
Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeIn,
|
||||
));
|
||||
|
||||
_slideAnimation =
|
||||
Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
Future<void> _startFresh() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
|
|
@ -31,19 +62,15 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
|
|||
try {
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
|
||||
// Удаляем все сообщения пользователя
|
||||
try {
|
||||
final api = ApiService();
|
||||
await api.deleteAllMessages();
|
||||
} catch (e) {
|
||||
print('Ошибка при удалении сообщений: $e');
|
||||
// Продолжаем даже если удаление сообщений не удалось
|
||||
}
|
||||
|
||||
// Удаляем старые ключи и создаем новые
|
||||
await authProvider.resetKeys();
|
||||
|
||||
// Переходим на экран настройки для создания новых ключей
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
|
|
@ -62,6 +89,7 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
|
|||
|
||||
Future<void> _recoverKeys() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
|
|
@ -73,11 +101,9 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
|
|||
final apiService = ApiService();
|
||||
final cryptoService = CryptoService();
|
||||
|
||||
// Получаем токен
|
||||
final token = await apiService.getAccessToken();
|
||||
if (token == null) throw Exception('Не авторизован');
|
||||
|
||||
// Скачиваем зашифрованный приватный ключ с сервера
|
||||
final response = await http.get(
|
||||
Uri.parse('${AppConstants.baseUrl}/users/me'),
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
|
|
@ -94,20 +120,16 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
|
|||
throw Exception('Зашифрованный ключ не найден на сервере');
|
||||
}
|
||||
|
||||
// Расшифровываем приватный ключ
|
||||
final decryptedPrivateKey = await cryptoService.decryptPrivateKey(
|
||||
encryptedPrivateKey,
|
||||
_passwordController.text,
|
||||
);
|
||||
|
||||
// Сохраняем расшифрованный ключ локально
|
||||
await cryptoService.savePrivateKey(decryptedPrivateKey);
|
||||
|
||||
// Обновляем статус в AuthProvider
|
||||
await authProvider.tryAutoLogin();
|
||||
|
||||
if (mounted) {
|
||||
// Возвращаемся на главный экран
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||
|
|
@ -125,177 +147,265 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Восстановление ключей'),
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
Icon(
|
||||
Icons.security_outlined,
|
||||
size: 80,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Восстановление ключей шифрования',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Вы переустановили приложение или используете новый девайс. У вас есть два варианта:',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
final themeProv = context.watch<ThemeProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Вариант 1: Начать с чистого листа
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.restart_alt_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Начать с чистого листа',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
if (themeProv.wallpaperPath != null)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: FileImage(File(themeProv.wallpaperPath!)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (themeProv.wallpaperPath != null)
|
||||
BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
|
||||
SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.security_outlined,
|
||||
size: 80,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Восстановление ключей',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Вы переустановили приложение или вошли на новом устройстве. Выберите один из вариантов:',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16, color: colorScheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Создаются новые ключи шифрования. Старые сообщения не будут расшифрованы.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Вариант 1: Начать с чистого листа
|
||||
_buildOptionCard(
|
||||
icon: Icons.restart_alt_outlined,
|
||||
title: 'Начать с чистого листа',
|
||||
description: 'Создаются новые ключи шифрования. Старые сообщения не будут расшифрованы.',
|
||||
buttonText: 'Продолжить',
|
||||
onPressed: _startFresh,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Вариант 2: Восстановить из облака
|
||||
_buildRecoveryCard(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Сообщение об ошибке
|
||||
if (_errorMessage != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(color: colorScheme.onErrorContainer, fontWeight: FontWeight.w500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _startFresh,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Продолжить'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOptionCard({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String description,
|
||||
required String buttonText,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.35),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.15),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: colorScheme.primary, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(description, style: TextStyle(color: colorScheme.outline, fontSize: 14)),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: Text(buttonText, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecoveryCard() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.35),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.15),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_download_outlined, color: colorScheme.primary, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Восстановить из облака',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Вариант 2: Восстановить из облака
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_download_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Восстановить из облака',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Введите мастер-пароль для восстановления ключей шифрования',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Мастер-пароль',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Введите мастер-пароль';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _recoverKeys,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Восстановить'),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Введите мастер-пароль для восстановления ключей шифрования',
|
||||
style: TextStyle(color: colorScheme.outline, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: _passwordController,
|
||||
label: "Мастер-пароль",
|
||||
icon: Icons.lock_outline,
|
||||
obscureText: true,
|
||||
validator: (value) => value!.isEmpty ? "Введите мастер-пароль" : null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _recoverKeys,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Text('Восстановить', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required IconData icon,
|
||||
bool obscureText = false,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Сообщение об ошибке
|
||||
if (_errorMessage != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container( // No blur here, it's inside the card
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
obscureText: obscureText,
|
||||
validator: validator,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: TextStyle(color: colorScheme.outline),
|
||||
prefixIcon: Icon(icon, color: colorScheme.outline),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -304,6 +414,7 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
|
|||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
import 'dart:ui';
|
||||
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
|
||||
import 'package:chepuhagram/presentation/screens/account_setup_screen.dart';
|
||||
import 'package:chepuhagram/presentation/screens/key_recovery_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../logic/auth_provider.dart';
|
||||
import '/core/theme_manager.dart';
|
||||
import 'dart:io'; // Import for File
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
class _LoginScreenState extends State<LoginScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
|
@ -20,124 +23,216 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
bool _showTotpField = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
|
||||
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
);
|
||||
|
||||
_fadeAnimation =
|
||||
Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeIn,
|
||||
));
|
||||
|
||||
_slideAnimation =
|
||||
Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
|
||||
Widget build(BuildContext context) {
|
||||
final authProvider = context.watch<AuthProvider>();
|
||||
final themeProv = context.watch<ThemeProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Иконка
|
||||
Icon(
|
||||
Icons.messenger_outline,
|
||||
size: 80,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Background Wallpaper
|
||||
if (themeProv.wallpaperPath != null)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: FileImage(File(themeProv.wallpaperPath!)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"Чепухаграм",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
),
|
||||
),
|
||||
// Blur Overlay
|
||||
if (themeProv.wallpaperPath != null)
|
||||
BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
|
||||
// Поле Логин
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Логин",
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
fillColor: Theme.of(context).colorScheme.primary,
|
||||
iconColor: Theme.of(context).colorScheme.primary,
|
||||
hoverColor: Theme.of(context).colorScheme.primary,
|
||||
focusColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
validator: (value) => value!.isEmpty ? "Введите логин" : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Поле Пароль
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Пароль",
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
fillColor: Theme.of(context).colorScheme.primary,
|
||||
iconColor: Theme.of(context).colorScheme.primary,
|
||||
hoverColor: Theme.of(context).colorScheme.primary,
|
||||
focusColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
validator: (value) =>
|
||||
value!.length < 6 ? "Минимум 6 символов" : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Поле TOTP, если требуется
|
||||
if (_showTotpField)
|
||||
TextFormField(
|
||||
controller: _totpController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "TOTP код",
|
||||
prefixIcon: const Icon(Icons.security),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.messenger_outline,
|
||||
size: 80,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"Чепухаграм",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
fillColor: Theme.of(context).colorScheme.primary,
|
||||
iconColor: Theme.of(context).colorScheme.primary,
|
||||
hoverColor: Theme.of(context).colorScheme.primary,
|
||||
focusColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
validator: (value) => value!.isEmpty ? "Введите TOTP код" : null,
|
||||
),
|
||||
if (_showTotpField) const SizedBox(height: 16),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Сообщение об ошибке
|
||||
if (_errorMessage != null)
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_errorMessage != null) const SizedBox(height: 16),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Кнопка Входа
|
||||
ElevatedButton(
|
||||
onPressed: authProvider.isLoading ? null : _submit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
// Поле Логин
|
||||
_buildTextField(
|
||||
controller: _usernameController,
|
||||
label: "Логин",
|
||||
icon: Icons.person_outline,
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? "Введите логин" : null,
|
||||
),
|
||||
),
|
||||
child: authProvider.isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text("Войти", style: TextStyle(fontSize: 16)),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Поле Пароль
|
||||
_buildTextField(
|
||||
controller: _passwordController,
|
||||
label: "Пароль",
|
||||
icon: Icons.lock_outline,
|
||||
obscureText: true,
|
||||
validator: (value) =>
|
||||
value!.length < 6 ? "Минимум 6 символов" : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Поле TOTP, если требуется
|
||||
if (_showTotpField)
|
||||
_buildTextField(
|
||||
controller: _totpController,
|
||||
label: "TOTP код",
|
||||
icon: Icons.security,
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? "Введите TOTP код" : null,
|
||||
),
|
||||
if (_showTotpField) const SizedBox(height: 16),
|
||||
|
||||
// Сообщение об ошибке
|
||||
if (_errorMessage != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (_errorMessage != null) const SizedBox(height: 16),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Кнопка Входа
|
||||
ElevatedButton(
|
||||
onPressed: authProvider.isLoading ? null : _submit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
),
|
||||
child: authProvider.isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text("Войти",
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required IconData icon,
|
||||
bool obscureText = false,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.35),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.15),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
obscureText: obscureText,
|
||||
validator: validator,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: TextStyle(color: colorScheme.outline),
|
||||
prefixIcon: Icon(icon, color: colorScheme.outline),
|
||||
border: InputBorder.none,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -146,9 +241,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
}
|
||||
|
||||
void _submit() async {
|
||||
try {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
// Сначала убираем фокус с полей, чтобы клавиатура скрылась
|
||||
FocusScope.of(context).unfocus();
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
try {
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
final success = await authProvider.login(
|
||||
_usernameController.text,
|
||||
|
|
@ -157,7 +256,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
);
|
||||
if (success && mounted) {
|
||||
await authProvider.initRealtime();
|
||||
|
||||
|
||||
// Определяем путь пользователя после входа
|
||||
if (authProvider.needsSetup) {
|
||||
// Путь А: Первичная настройка
|
||||
|
|
@ -181,24 +280,23 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
}
|
||||
} catch (e) {
|
||||
final error = e.toString().replaceAll('Exception: ', '');
|
||||
if (error.contains('TOTP код требуется')) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showTotpField = true;
|
||||
_errorMessage = error;
|
||||
if (error.contains('TOTP код требуется')) {
|
||||
_showTotpField = true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_totpController.dispose();
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import 'package:provider/provider.dart';
|
|||
import 'package:image_picker/image_picker.dart';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import 'dart:math';
|
||||
import '/logic/auth_provider.dart';
|
||||
import 'account_settings_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
|
@ -99,8 +99,8 @@ class _MyProfileScreenState extends State<MyProfileScreen> {
|
|||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.fastOutSlowIn,
|
||||
width: _isAvatarExpanded ? screenWidth : 130.0,
|
||||
height: _isAvatarExpanded ? screenWidth : 130.0,
|
||||
width: _isAvatarExpanded ? max(screenWidth, 200) : 130.0,
|
||||
height: _isAvatarExpanded ? max(screenWidth, 200) : 130.0,
|
||||
margin: _isAvatarExpanded
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
|
|
@ -169,7 +169,7 @@ class _MyProfileScreenState extends State<MyProfileScreen> {
|
|||
Expanded(
|
||||
child: _buildActionButton(
|
||||
icon: Icons.photo_camera_rounded,
|
||||
label: 'Фото',
|
||||
label: 'Поставить фото',
|
||||
onTap: _pickAvatar,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../logic/auth_provider.dart';
|
||||
import '../../logic/contact_provider.dart';
|
||||
import 'login_screen.dart';
|
||||
import 'contacts_screen.dart';
|
||||
import 'account_setup_screen.dart';
|
||||
import 'key_recovery_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'package:chepuhagram/logic/auth_provider.dart';
|
||||
import 'package:chepuhagram/logic/contact_provider.dart';
|
||||
import 'package:chepuhagram/presentation/screens/login_screen.dart';
|
||||
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
|
||||
import 'package:chepuhagram/presentation/screens/account_setup_screen.dart';
|
||||
import 'package:chepuhagram/presentation/screens/key_recovery_screen.dart';
|
||||
import 'package:chepuhagram/presentation/screens/chat_screen.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:chepuhagram/main.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
|
@ -15,6 +15,9 @@ import 'dart:convert';
|
|||
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:chepuhagram/core/theme_manager.dart';
|
||||
import 'dart:ui';
|
||||
import 'dart:io';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
|
@ -23,11 +26,16 @@ class SplashScreen extends StatefulWidget {
|
|||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen> {
|
||||
class _SplashScreenState extends State<SplashScreen>
|
||||
with TickerProviderStateMixin {
|
||||
int? _targetChatId;
|
||||
String? connectError;
|
||||
String? _statusMessage;
|
||||
|
||||
late AnimationController _fadeController;
|
||||
late AnimationController _pulseController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<double> _pulseAnimation;
|
||||
|
||||
// Ключ для SharedPreferences
|
||||
static const String _notificationLaunchKey = 'notification_launch_data';
|
||||
static const String _contactPublicKey = 'contact_public_key_';
|
||||
static const String _contactSharedKey = 'contact_shared_key_';
|
||||
|
|
@ -35,299 +43,252 @@ class _SplashScreenState extends State<SplashScreen> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
print('SplashScreen initState');
|
||||
_fadeController = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 800));
|
||||
_pulseController = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 1200));
|
||||
|
||||
_fadeAnimation =
|
||||
Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(
|
||||
parent: _fadeController,
|
||||
curve: Curves.easeIn,
|
||||
));
|
||||
|
||||
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.15)
|
||||
.animate(CurvedAnimation(
|
||||
parent: _pulseController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
_pulseController.repeat(reverse: true);
|
||||
_fadeController.forward();
|
||||
|
||||
_setupNotificationHandler();
|
||||
_initializeApp();
|
||||
}
|
||||
|
||||
void _setupNotificationHandler() {
|
||||
print('Setting up notification handler');
|
||||
// Обработка открытия приложения из уведомления
|
||||
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
|
||||
print('App opened from notification: ${message.data}');
|
||||
if (message.data['type'] == 'enc_message') {
|
||||
final senderId = int.tryParse(
|
||||
message.data['sender_id']?.toString() ?? '',
|
||||
);
|
||||
final senderId = int.tryParse(message.data['sender_id']?.toString() ?? '');
|
||||
if (senderId != null) {
|
||||
setState(() {
|
||||
_targetChatId = senderId;
|
||||
});
|
||||
print('Set target chat from opened app: $senderId');
|
||||
setState(() => _targetChatId = senderId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initializeApp() async {
|
||||
// 1. Искусственная задержка в 2 секунды для демонстрации splash
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _statusMessage = "Подключение...");
|
||||
|
||||
// 2. Пытаемся выполнить автологин
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
bool? isLoggedIn;
|
||||
try {
|
||||
isLoggedIn = await authProvider.tryAutoLogin();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
connectError =
|
||||
'$e+_sps_init_1'.replaceAll('Exception: ', '');
|
||||
});
|
||||
setState(() => _statusMessage = 'Ошибка входа: ${e.toString().replaceAll('Exception: ', '')}');
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
if (mounted) _navigateTo(const LoginScreen());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
bool connected = false;
|
||||
int connectAttempt = 0;
|
||||
// 3. Навигация в зависимости от результата и статуса аккаунта
|
||||
|
||||
if (isLoggedIn) {
|
||||
setState(() => _statusMessage = "Аутентификация...");
|
||||
bool connected = false;
|
||||
int connectAttempt = 1;
|
||||
while (!connected) {
|
||||
try {
|
||||
await authProvider.initRealtime();
|
||||
connected = true;
|
||||
} catch (e) {
|
||||
setState(() => _statusMessage = 'Соединение... (попытка $connectAttempt)');
|
||||
connectAttempt++;
|
||||
if (e.toString().contains('timeout')) {
|
||||
setState(() {
|
||||
connectError =
|
||||
'Превышено время ожидания.\n Проверьте интернет соеденение.\n Попытка соеденения: $connectAttempt';
|
||||
});
|
||||
} else if (e.toString().contains('Failed host lookup')) {
|
||||
setState(() {
|
||||
connectError =
|
||||
'Сервер недоступен. Проверьте интернет соеденение.\n Попытка соеденения: $connectAttempt';
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
connectError = e.toString().replaceAll('Exception: ', '');
|
||||
});
|
||||
}
|
||||
|
||||
await Future.delayed(Duration(seconds: 2));
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _statusMessage = "Загрузка профиля...");
|
||||
await authProvider.refreshMe();
|
||||
|
||||
// Определяем путь пользователя
|
||||
if (authProvider.needsSetup) {
|
||||
// Путь А: Первичная настройка
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AccountSetupScreen()),
|
||||
);
|
||||
_navigateTo(const AccountSetupScreen());
|
||||
} else if (authProvider.needsKeyRecovery) {
|
||||
// Путь В: Восстановление ключей
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const KeyRecoveryScreen()),
|
||||
);
|
||||
_navigateTo(const KeyRecoveryScreen());
|
||||
} else {
|
||||
// Путь Б: Нормальный вход в контакты
|
||||
// Проверяем, было ли приложение запущено из уведомления
|
||||
int? targetChatId =
|
||||
_targetChatId; // Сначала проверяем из onMessageOpenedApp
|
||||
if (targetChatId == null) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedData = prefs.getString(_notificationLaunchKey);
|
||||
|
||||
try {
|
||||
final contactProvider = context.read<ContactProvider>();
|
||||
contactProvider.setCurrentUserId(authProvider.currentUserId);
|
||||
|
||||
await contactProvider.loadContacts(enrichContacts: false);
|
||||
|
||||
final myPrivKeyBase64 = await context
|
||||
.read<CryptoService>()
|
||||
.getPrivateKey();
|
||||
|
||||
if (myPrivKeyBase64 != null) {
|
||||
final Map<int, String> keysToCompute = {};
|
||||
for (var c in contactProvider.contacts) {
|
||||
final savedKeyHex = prefs.getString(
|
||||
'$_contactSharedKey${c.id}',
|
||||
);
|
||||
final savedPubKey = prefs.getString(
|
||||
'$_contactPublicKey${c.id}',
|
||||
);
|
||||
if (savedKeyHex != null && savedPubKey == c.publicKey) {
|
||||
final bytes = base64Decode(savedKeyHex);
|
||||
contactProvider.setSharedKey(c.id, SecretKey(bytes));
|
||||
} else if (c.publicKey != null) {
|
||||
keysToCompute[c.id] = c.publicKey!;
|
||||
}
|
||||
}
|
||||
print(
|
||||
'Contacts with keys for isolate: ${keysToCompute.keys.toList()}',
|
||||
);
|
||||
|
||||
final String privKey = myPrivKeyBase64;
|
||||
final computedKeys = await compute(
|
||||
CryptoService.computeSharedKeysTask,
|
||||
{'keysMap': keysToCompute, 'privKey': privKey},
|
||||
);
|
||||
|
||||
computedKeys.forEach((id, bytes) {
|
||||
contactProvider.setSharedKey(id, SecretKey(bytes));
|
||||
prefs.setString('$_contactSharedKey$id', base64Encode(bytes));
|
||||
prefs.setString('$_contactPublicKey$id', keysToCompute[id]!);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print("Ошибка при загрузке контактов или вычислении ключей: $e");
|
||||
}
|
||||
|
||||
// Если не установлено, проверяем SharedPreferences
|
||||
|
||||
if (savedData != null) {
|
||||
try {
|
||||
final data = jsonDecode(savedData) as Map<String, dynamic>;
|
||||
print('Found saved notification data: $data');
|
||||
final senderId = int.tryParse(
|
||||
data['sender_id']?.toString() ?? '',
|
||||
);
|
||||
final type = data['type']?.toString();
|
||||
|
||||
// Поддерживаем старый payload (только sender_id) и новый (type+sender_id)
|
||||
if (senderId != null && (type == null || type == 'enc_message')) {
|
||||
targetChatId = senderId;
|
||||
print(
|
||||
'App launched from saved notification, target chat: $targetChatId',
|
||||
);
|
||||
}
|
||||
|
||||
// Очищаем сохраненные данные после использования
|
||||
await prefs.remove(_notificationLaunchKey);
|
||||
} catch (e) {
|
||||
print('Error parsing saved notification data: $e');
|
||||
await prefs.remove(_notificationLaunchKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Также проверяем initialMessage как fallback
|
||||
if (targetChatId == null) {
|
||||
print('Checking initialMessage: $initialMessage');
|
||||
if (initialMessage != null) {
|
||||
print('Initial message data: ${initialMessage!.data}');
|
||||
if (initialMessage!.data['type'] == 'enc_message') {
|
||||
targetChatId = int.tryParse(
|
||||
initialMessage!.data['sender_id']?.toString() ?? '',
|
||||
);
|
||||
print('Set target chat from initialMessage: $targetChatId');
|
||||
} else {
|
||||
print(
|
||||
'Initial message type is not enc_message: ${initialMessage!.data['type']}',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
print('No initial message found');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print('Using targetChatId from onMessageOpenedApp: $targetChatId');
|
||||
}
|
||||
|
||||
if (targetChatId != null) {
|
||||
print(
|
||||
'Notification targetChatId resolved: $targetChatId, trying to open chat directly',
|
||||
);
|
||||
try {
|
||||
final contactProvider = context.read<ContactProvider>();
|
||||
contactProvider.setCurrentUserId(authProvider.currentUserId);
|
||||
await contactProvider.loadContacts(enrichContacts: false);
|
||||
|
||||
final contact = contactProvider.contacts.firstWhere(
|
||||
(c) => c.id == targetChatId,
|
||||
);
|
||||
currentActiveChatContactId = targetChatId;
|
||||
print(
|
||||
'Directly navigating to ChatScreen for contact: ${contact.username}',
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_notificationLaunchKey);
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
|
||||
);
|
||||
return;
|
||||
} catch (e) {
|
||||
print(
|
||||
'Failed to open chat directly, falling back to ContactsScreen: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
print('Navigating to ContactsScreen, targetChatId: $targetChatId');
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_notificationLaunchKey);
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ContactsScreen(targetChatId: targetChatId),
|
||||
),
|
||||
);
|
||||
setState(() => _statusMessage = "Загрузка контактов...");
|
||||
_loadContactsAndNavigate(authProvider.currentUserId);
|
||||
}
|
||||
} else {
|
||||
// Нет токена - переходим на экран входа
|
||||
_navigateTo(const LoginScreen());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadContactsAndNavigate(int? currentUserId) async {
|
||||
// Navigate to ContactsScreen while contacts are loading in the background
|
||||
_navigateTo(ContactsScreen(targetChatId: await _getTargetChatId()));
|
||||
|
||||
try {
|
||||
final contactProvider = context.read<ContactProvider>();
|
||||
contactProvider.setCurrentUserId(currentUserId);
|
||||
await contactProvider.loadContacts(enrichContacts: false);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final myPrivKeyBase64 = await context.read<CryptoService>().getPrivateKey();
|
||||
|
||||
if (myPrivKeyBase64 != null) {
|
||||
final Map<int, String> keysToCompute = {};
|
||||
for (var c in contactProvider.contacts) {
|
||||
final savedKeyHex = prefs.getString('$_contactSharedKey${c.id}');
|
||||
final savedPubKey = prefs.getString('$_contactPublicKey${c.id}');
|
||||
if (savedKeyHex != null && savedPubKey == c.publicKey) {
|
||||
contactProvider.setSharedKey(c.id, SecretKey(base64Decode(savedKeyHex)));
|
||||
} else if (c.publicKey != null) {
|
||||
keysToCompute[c.id] = c.publicKey!;
|
||||
}
|
||||
}
|
||||
|
||||
final computedKeys = await compute(
|
||||
CryptoService.computeSharedKeysTask,
|
||||
{'keysMap': keysToCompute, 'privKey': myPrivKeyBase64},
|
||||
);
|
||||
|
||||
computedKeys.forEach((id, bytes) {
|
||||
contactProvider.setSharedKey(id, SecretKey(bytes));
|
||||
prefs.setString('$_contactSharedKey$id', base64Encode(bytes));
|
||||
prefs.setString('$_contactPublicKey$id', keysToCompute[id]!);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print("Ошибка при фоновой загрузке контактов или ключей: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<int?> _getTargetChatId() async {
|
||||
int? targetChatId = _targetChatId;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
if (targetChatId == null) {
|
||||
final savedData = prefs.getString(_notificationLaunchKey);
|
||||
if (savedData != null) {
|
||||
try {
|
||||
final data = jsonDecode(savedData) as Map<String, dynamic>;
|
||||
targetChatId = int.tryParse(data['sender_id']?.toString() ?? '');
|
||||
} catch (e) {
|
||||
print('Error parsing saved notification data: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetChatId == null && initialMessage != null) {
|
||||
if (initialMessage!.data['type'] == 'enc_message') {
|
||||
targetChatId = int.tryParse(initialMessage!.data['sender_id']?.toString() ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
await prefs.remove(_notificationLaunchKey);
|
||||
return targetChatId;
|
||||
}
|
||||
|
||||
void _navigateTo(Widget screen) {
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
MaterialPageRoute(builder: (_) => screen),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fadeController.dispose();
|
||||
_pulseController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeProv = context.watch<ThemeProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(),
|
||||
Icon(
|
||||
Icons.messenger_outline,
|
||||
size: 80,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
"Chepuhagram",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
body: Stack(
|
||||
children: [
|
||||
if (themeProv.wallpaperPath != null)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: FileImage(File(themeProv.wallpaperPath!)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
// Мягкий индикатор загрузки снизу
|
||||
CircularProgressIndicator(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
if (themeProv.wallpaperPath != null)
|
||||
BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
|
||||
child: Container(color: Colors.black.withOpacity(0.1)),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
Text(
|
||||
connectError ?? '',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Made by ArturKarasevich',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontSize: 12,
|
||||
|
||||
Center(
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(),
|
||||
ScaleTransition(
|
||||
scale: _pulseAnimation,
|
||||
child: Icon(
|
||||
Icons.messenger_outline,
|
||||
size: 80,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
"Chepuhagram",
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: _statusMessage != null
|
||||
? Text(
|
||||
_statusMessage!,
|
||||
key: ValueKey(_statusMessage),
|
||||
style: TextStyle(
|
||||
color: colorScheme.outline,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
const CircularProgressIndicator(),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Made by ArturKarasevich',
|
||||
style: TextStyle(
|
||||
color: colorScheme.outline,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '/data/models/message_model.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
|
@ -22,6 +24,8 @@ class MessageBubble extends StatefulWidget {
|
|||
final VoidCallback? onTap;
|
||||
final VoidCallback? onReplyTap;
|
||||
final VoidCallback? onImageTap;
|
||||
final VoidCallback? onEditTap;
|
||||
final VoidCallback? onDeleteTap;
|
||||
|
||||
final Future<void>? Function(MessageModel)? onDownloadRequested;
|
||||
final Future<void>? Function(MessageModel)? onDownloadRequestedWithoutLoad;
|
||||
|
|
@ -35,6 +39,8 @@ class MessageBubble extends StatefulWidget {
|
|||
this.onTap,
|
||||
this.onReplyTap,
|
||||
this.onImageTap,
|
||||
this.onEditTap,
|
||||
this.onDeleteTap,
|
||||
this.onDownloadRequested,
|
||||
this.onDownloadRequestedWithoutLoad,
|
||||
this.onDownloadStoped,
|
||||
|
|
@ -529,6 +535,103 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||
widget.message.localFile!.existsSync();
|
||||
}
|
||||
|
||||
void _showContextMenu(TapDownDetails details) {
|
||||
final RenderBox overlay =
|
||||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||
final RelativeRect position = RelativeRect.fromRect(
|
||||
Rect.fromLTWH(details.globalPosition.dx, details.globalPosition.dy, 30, 30),
|
||||
Offset.zero & overlay.size);
|
||||
|
||||
showMenu<String>(
|
||||
context: context,
|
||||
position: position,
|
||||
items: [
|
||||
const PopupMenuItem<String>(
|
||||
value: 'reply',
|
||||
child: Text('Reply'),
|
||||
),
|
||||
if (widget.message.text.isNotEmpty)
|
||||
const PopupMenuItem<String>(
|
||||
value: 'copy',
|
||||
child: Text('Copy Text'),
|
||||
),
|
||||
if (widget.message.isMe)
|
||||
const PopupMenuItem<String>(
|
||||
value: 'edit',
|
||||
child: Text('Edit'),
|
||||
),
|
||||
if (widget.message.isMe)
|
||||
const PopupMenuItem<String>(
|
||||
value: 'delete',
|
||||
child: Text('Delete'),
|
||||
),
|
||||
],
|
||||
).then((String? value) {
|
||||
if (value != null) {
|
||||
_handleMenuSelection(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleMenuSelection(String value) {
|
||||
switch (value) {
|
||||
case 'reply':
|
||||
widget.onReplyTap?.call();
|
||||
break;
|
||||
case 'copy':
|
||||
Clipboard.setData(ClipboardData(text: widget.message.text));
|
||||
break;
|
||||
case 'edit':
|
||||
widget.onEditTap?.call();
|
||||
break;
|
||||
case 'delete':
|
||||
widget.onDeleteTap?.call();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
TextSpan _buildTextSpan(String text, Color primaryColor, Color linkColor, double fontSize) {
|
||||
final List<InlineSpan> children = [];
|
||||
final RegExp linkRegExp = RegExp(
|
||||
r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+');
|
||||
final matches = linkRegExp.allMatches(text);
|
||||
|
||||
int lastMatchEnd = 0;
|
||||
for (final Match match in matches) {
|
||||
if (match.start > lastMatchEnd) {
|
||||
children.add(TextSpan(text: text.substring(lastMatchEnd, match.start)));
|
||||
}
|
||||
final String linkText = match.group(0)!;
|
||||
children.add(
|
||||
TextSpan(
|
||||
text: linkText,
|
||||
style: TextStyle(color: linkColor, fontWeight: FontWeight.bold, decoration: TextDecoration.underline),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () async {
|
||||
String url = linkText;
|
||||
if (!url.startsWith('http')) {
|
||||
url = 'https://$url';
|
||||
}
|
||||
final Uri uri = Uri.parse(url);
|
||||
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
|
||||
throw Exception('Could not launch $uri');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
lastMatchEnd = match.end;
|
||||
}
|
||||
|
||||
if (lastMatchEnd < text.length) {
|
||||
children.add(TextSpan(text: text.substring(lastMatchEnd)));
|
||||
}
|
||||
|
||||
return TextSpan(
|
||||
style: TextStyle(color: primaryColor, fontSize: fontSize),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMe = widget.message.isMe;
|
||||
|
|
@ -550,69 +653,68 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||
|
||||
return Align(
|
||||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
onLongPress: widget.onTap,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isMe ? 16 : 0),
|
||||
bottomRight: Radius.circular(isMe ? 0 : 16),
|
||||
),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
padding: EdgeInsets.symmetric(vertical: paddingVertical, horizontal: paddingHorizontal),
|
||||
constraints: BoxConstraints(
|
||||
// Максимальная ширина на больших мониторах ограничена 460px (как в Telegram Desktop)
|
||||
maxWidth: math.min(screenWidth * 0.75, 460.0),
|
||||
child: GestureDetector(
|
||||
onSecondaryTapDown: _showContextMenu,
|
||||
onLongPressStart: (details) {
|
||||
final tapDownDetails =
|
||||
TapDownDetails(globalPosition: details.globalPosition);
|
||||
_showContextMenu(tapDownDetails);
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isMe ? 16 : 0),
|
||||
bottomRight: Radius.circular(isMe ? 0 : 16),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isMe
|
||||
? Theme.of(context).colorScheme.brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.primary
|
||||
: Colors.grey[800],
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isMe ? 16 : 0),
|
||||
bottomRight: Radius.circular(isMe ? 0 : 16),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
padding: EdgeInsets.symmetric(vertical: paddingVertical, horizontal: paddingHorizontal),
|
||||
constraints: BoxConstraints(
|
||||
// Максимальная ширина на больших мониторах ограничена 460px (как в Telegram Desktop)
|
||||
maxWidth: math.min(screenWidth * 0.75, 460.0),
|
||||
),
|
||||
),
|
||||
child: IntrinsicWidth(
|
||||
child: Column(
|
||||
crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.message.replyToText != null) ...[
|
||||
_buildReplyWidget(isMe, secondaryTextColor, replyFontSize),
|
||||
],
|
||||
Align(
|
||||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: _buildMessageBody(primaryTextColor, secondaryTextColor, isLargeScreen),
|
||||
),
|
||||
if (widget.message.messageType == MessageType.text ||
|
||||
widget.message.text.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isMe
|
||||
? Theme.of(context).colorScheme.brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.primary
|
||||
: Colors.grey[800],
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isMe ? 16 : 0),
|
||||
bottomRight: Radius.circular(isMe ? 0 : 16),
|
||||
),
|
||||
),
|
||||
child: IntrinsicWidth(
|
||||
child: Column(
|
||||
crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.message.replyToText != null) ...[
|
||||
_buildReplyWidget(isMe, secondaryTextColor, replyFontSize),
|
||||
],
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Linkify(
|
||||
onOpen: (link) async {
|
||||
final Uri url = Uri.parse(link.url);
|
||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
throw Exception('Could not launch $url');
|
||||
}
|
||||
},
|
||||
text: widget.message.text,
|
||||
style: TextStyle(color: primaryTextColor, fontSize: bodyFontSize),
|
||||
linkStyle: TextStyle(color: linkColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: _buildMessageBody(primaryTextColor, secondaryTextColor, isLargeScreen),
|
||||
),
|
||||
if (widget.message.text.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SelectableText.rich(
|
||||
_buildTextSpan(widget.message.text, primaryTextColor, linkColor, bodyFontSize),
|
||||
style: TextStyle(color: primaryTextColor, fontSize: bodyFontSize),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
_buildTimeAndStatusRow(isMe, secondaryTextColor, timeFontSize),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
_buildTimeAndStatusRow(isMe, secondaryTextColor, timeFontSize),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -634,6 +736,11 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||
case MessageType.voiceNote:
|
||||
return _buildVoiceNoteBubble(primaryColor, secondaryColor, isLargeScreen);
|
||||
default:
|
||||
// For text-only messages, we don't need a body here as it's handled outside.
|
||||
if (widget.message.messageType == MessageType.text) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// Fallback for any other case
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
|
@ -1576,7 +1683,8 @@ class _InlineVideoInitErrorFallback extends StatelessWidget {
|
|||
children: [
|
||||
Icon(Icons.play_disabled, color: Colors.white70, size: 40),
|
||||
SizedBox(height: 8),
|
||||
Text('Видео не воспроизводится\n Нажмите, чтобы открыть внешним плеером', textAlign: TextAlign.center, style: TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
Text('Видео не воспроизводится
|
||||
Нажмите, чтобы открыть внешним плеером', textAlign: TextAlign.center, style: TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -1876,4 +1984,4 @@ class _InlineVoiceNotePlayerState extends State<InlineVoiceNotePlayer> {
|
|||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
Loading…
Reference in New Issue