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