22-06-2026+10-13

This commit is contained in:
Artur 2026-06-22 10:13:45 +05:00
parent c3999db9eb
commit 04c3772bb6
19 changed files with 836 additions and 134 deletions

View File

@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />

View File

@ -17,6 +17,7 @@ class Contact {
final int? lastMessageId;
final MessageType? lastMessageType;
int? firstUnreadMessageId;
final String? phone;
String? get effectiveAvatarUrl {
if (avatarFileId != null && avatarFileId!.isNotEmpty) {
@ -42,6 +43,7 @@ class Contact {
this.lastMessageId,
this.lastMessageType,
this.firstUnreadMessageId,
this.phone,
});
Contact copyWith({
@ -60,6 +62,7 @@ class Contact {
int? lastMessageId,
MessageType? lastMessageType,
int? firstUnreadMessageId,
String? phone,
}) {
return Contact(
id: id ?? this.id,
@ -77,6 +80,7 @@ class Contact {
lastMessageId: lastMessageId ?? this.lastMessageId,
lastMessageType: lastMessageType ?? this.lastMessageType,
firstUnreadMessageId: firstUnreadMessageId ?? this.firstUnreadMessageId,
phone: phone ?? this.phone,
);
}
@ -104,6 +108,7 @@ class Contact {
lastMessageId: int.tryParse((json['last_message_id'] ?? json['lastMessageId'] ?? 0).toString()) ?? 0,
lastMessageType: MessageModel.parseMessageType(json['last_message_type'] ?? json['lastMessageType'] ?? 'text'),
firstUnreadMessageId: int.tryParse((json['first_unread_message_id'] ?? json['firstUnreadMessageId'] ?? 0).toString()) ?? 0,
phone: json['phone'] ?? json['phone_number'],
);
}
}

View File

@ -451,8 +451,12 @@ class AuthProvider extends ChangeNotifier {
Future<bool> setupAccount(
String firstName,
String? lastName,
String masterPassword,
) async {
String masterPassword, {
String? phone,
String? email,
String? about,
}) async {
_isLoading = true;
notifyListeners();
try {
@ -476,7 +480,18 @@ class AuthProvider extends ChangeNotifier {
);
if (response.statusCode == 200) {
final String currentUsername = _username ?? '';
await _apiService.updateMe(
username: currentUsername,
firstName: firstName,
lastName: lastName ?? '',
phone: phone,
email: email,
about: about,
);
_needsSetup = false;
await _checkAccountStatus();
notifyListeners();
return true;
} else {
@ -487,6 +502,7 @@ class AuthProvider extends ChangeNotifier {
print("Ошибка сети: $e");
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}

View File

@ -243,6 +243,13 @@ class ContactProvider extends ChangeNotifier {
try {
final index = _contacts.indexWhere((c) => c.id == contactId);
if (index != -1) {
if (lastMessage == null && lastMessageTime == null && lastMessageId == null) {
_contacts.removeAt(index);
print("Контакт $contactId удален из списка чатов, так как сообщений не осталось.");
_sortContacts();
notifyListeners();
return;
}
final existing = _contacts[index];
String displayMessage;
if (isEdited) {

View File

@ -43,8 +43,8 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
Future<bool> _save() async {
if (!_formKey.currentState!.validate()) return false;
setState(() => _isSaving = true);
try {
final api = ApiService();
@ -57,19 +57,16 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
about: _aboutController.text,
);
if (!mounted) return;
if (!mounted) return true;
await context.read<AuthProvider>().refreshMe();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Сохранено'), behavior: SnackBarBehavior.floating),
);
Navigator.of(context).pop();
return true;
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', '')), behavior: SnackBarBehavior.floating),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', '')), behavior: SnackBarBehavior.floating),
);
}
return false;
} finally {
if (mounted) setState(() => _isSaving = false);
}
@ -79,32 +76,19 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colorScheme.background,
appBar: AppBar(
title: Text('Редактировать аккаунт', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)),
elevation: 0,
backgroundColor: Colors.transparent,
iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Center(
child: _isSaving
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2.5, color: colorScheme.primary),
)
: TextButton.icon(
onPressed: _save,
icon: const Icon(Icons.done_rounded, size: 18),
label: const Text('Готово', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
),
],
),
return WillPopScope(
onWillPop: () async {
final success = await _save();
return success;
},
child: Scaffold(
backgroundColor: colorScheme.background,
appBar: AppBar(
title: Text('Редактировать аккаунт', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)),
elevation: 0,
backgroundColor: Colors.transparent,
iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
),
body: Form(
key: _formKey,
child: ListView(
@ -159,20 +143,23 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
label: 'О себе',
hint: 'Расскажите немного о себе',
icon: Icons.short_text_rounded,
maxLines: 4,
minLines: 1,
maxLines: 50,
),
],
),
),
);
}
),
);
}
Widget _buildInputField({
required TextEditingController controller,
required String label,
required String hint,
required IconData icon,
int maxLines = 1,
int? minLines,
int? maxLines = 1,
TextInputType? keyboardType,
String? Function(String?)? validator,
}) {
@ -188,6 +175,7 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: TextFormField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
keyboardType: keyboardType,
validator: validator,

View File

@ -18,6 +18,9 @@ class _AccountSetupScreenState extends State<AccountSetupScreen>
final _formKey = GlobalKey<FormState>();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _phoneController = TextEditingController();
final _emailController = TextEditingController();
final _aboutController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
@ -46,6 +49,14 @@ class _AccountSetupScreenState extends State<AccountSetupScreen>
);
_animationController.forward();
// Заполняем поля данными, если они уже есть на сервере
final auth = context.read<AuthProvider>();
_firstNameController.text = auth.firstName ?? '';
_lastNameController.text = auth.lastName ?? '';
_phoneController.text = auth.phone ?? '';
_emailController.text = auth.email ?? '';
_aboutController.text = auth.about ?? '';
}
@override
@ -91,7 +102,7 @@ class _AccountSetupScreenState extends State<AccountSetupScreen>
),
const SizedBox(height: 12),
Text(
"Укажите ваше имя и создайте мастер-пароль",
"Укажите ваши данные и создайте мастер-пароль",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
@ -102,7 +113,7 @@ class _AccountSetupScreenState extends State<AccountSetupScreen>
),
),
),
const SizedBox(height: 48),
const SizedBox(height: 36),
// Поле Имя
_buildTextField(
@ -120,6 +131,33 @@ class _AccountSetupScreenState extends State<AccountSetupScreen>
label: "Фамилия",
icon: Icons.person_outline,
),
const SizedBox(height: 16),
// Поле Телефон
_buildTextField(
controller: _phoneController,
label: "Телефон",
icon: Icons.phone_android_outlined,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 16),
// Поле Почта
_buildTextField(
controller: _emailController,
label: "Электронная почта",
icon: Icons.mail_outline_rounded,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
// Поле О себе
_buildTextField(
controller: _aboutController,
label: "О себе",
icon: Icons.info_outline_rounded,
maxLines: 3,
),
const SizedBox(height: 24),
// Поле Мастер-пароль
@ -153,7 +191,7 @@ class _AccountSetupScreenState extends State<AccountSetupScreen>
return null;
},
),
const SizedBox(height: 40),
const SizedBox(height: 32),
// Кнопка Продолжить
ElevatedButton(
@ -201,6 +239,8 @@ class _AccountSetupScreenState extends State<AccountSetupScreen>
required IconData icon,
String? Function(String?)? validator,
bool obscureText = false,
TextInputType? keyboardType,
int maxLines = 1,
}) {
final colorScheme = Theme.of(context).colorScheme;
@ -221,6 +261,8 @@ class _AccountSetupScreenState extends State<AccountSetupScreen>
controller: controller,
validator: validator,
obscureText: obscureText,
keyboardType: keyboardType,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(color: colorScheme.outline),
@ -247,6 +289,9 @@ class _AccountSetupScreenState extends State<AccountSetupScreen>
_firstNameController.text.trim(),
_lastNameController.text.trim(),
_passwordController.text,
phone: _phoneController.text.trim(),
email: _emailController.text.trim(),
about: _aboutController.text.trim(),
);
if (mounted) {
@ -272,6 +317,9 @@ class _AccountSetupScreenState extends State<AccountSetupScreen>
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_phoneController.dispose();
_emailController.dispose();
_aboutController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_animationController.dispose();

View File

@ -41,6 +41,7 @@ import 'package:drift/drift.dart' as drift;
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:flutter_contacts/flutter_contacts.dart' as fc;
class ChatScreen extends StatefulWidget {
final Contact contact;
@ -230,14 +231,48 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
final String? savedSurname = prefs.getString(
'lastname_${_currentContact.id}',
);
print('Загружены имя $savedName, $savedSurname');
String? phoneBookName;
if ((savedName == null && savedSurname == null) &&
_currentContact.phone != null &&
(Platform.isAndroid || Platform.isIOS)) {
try {
final hasPermission = await fc.FlutterContacts.requestPermission(readonly: true);
if (hasPermission) {
final deviceContacts = await fc.FlutterContacts.getContacts(withProperties: true);
String normalizePhone(String p) {
final digits = p.replaceAll(RegExp(r'\D'), '');
if (digits.length >= 10) {
return digits.substring(digits.length - 10);
}
return digits;
}
final normTarget = normalizePhone(_currentContact.phone!);
if (normTarget.isNotEmpty) {
for (var dc in deviceContacts) {
for (var phoneObj in dc.phones) {
if (normalizePhone(phoneObj.number) == normTarget) {
phoneBookName = dc.displayName;
break;
}
}
if (phoneBookName != null) break;
}
}
}
} catch (e) {
print("Ошибка получения имени контакта из телефонной книги в чате: $e");
}
}
if (mounted) {
setState(() {
if (savedName != null) {
_currentContact.name = savedName;
}
if (savedSurname != null) {
_currentContact.surname = savedSurname;
if (savedName != null || savedSurname != null) {
_currentContact.name = savedName ?? '';
_currentContact.surname = savedSurname ?? '';
} else if (phoneBookName != null && phoneBookName!.isNotEmpty) {
_currentContact.name = phoneBookName!;
_currentContact.surname = '';
}
});
}

View File

@ -31,6 +31,7 @@ import 'admin_broadcast_screen.dart';
import 'package:url_launcher/url_launcher.dart';
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_contacts/flutter_contacts.dart' as fc;
class ContactsScreen extends StatefulWidget {
final int? targetChatId;
@ -71,6 +72,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
// Хранилище стабильно загруженных локальных имён
Map<int, String> _localFullNames = {};
Map<int, String> _phoneBookNames = {};
final Map<int, int> _pendingUnreadCounters = {};
@ -128,11 +130,64 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
});
}
Future<void> _loadPhoneBookNames() async {
if (!Platform.isAndroid && !Platform.isIOS) return;
try {
final hasPermission = await fc.FlutterContacts.requestPermission(readonly: true);
if (!hasPermission) return;
final deviceContacts = await fc.FlutterContacts.getContacts(withProperties: true);
final contactProvider = context.read<ContactProvider>();
final Map<int, String> matchedNames = {};
String normalizePhone(String p) {
final digits = p.replaceAll(RegExp(r'\D'), '');
if (digits.length >= 10) {
return digits.substring(digits.length - 10);
}
return digits;
}
final Map<String, int> phoneToContactId = {};
for (var contact in contactProvider.contacts) {
if (contact.phone != null) {
final norm = normalizePhone(contact.phone!);
if (norm.isNotEmpty) {
phoneToContactId[norm] = contact.id;
}
}
}
for (var dc in deviceContacts) {
for (var phoneObj in dc.phones) {
final norm = normalizePhone(phoneObj.number);
if (norm.isNotEmpty && phoneToContactId.containsKey(norm)) {
final contactId = phoneToContactId[norm]!;
if (dc.displayName.isNotEmpty) {
matchedNames[contactId] = dc.displayName;
}
break;
}
}
}
if (mounted) {
setState(() {
_phoneBookNames = matchedNames;
});
}
} catch (e) {
print("Ошибка загрузки имен из телефонной книги: $e");
}
}
Future<void> _initContacts() async {
if (_contactsLoaded) return;
final contactProvider = context.read<ContactProvider>();
await contactProvider.loadContacts();
await _loadLocalNames(); // Гарантированный вызов после загрузки контактов
await _loadLocalNames();
await _loadPhoneBookNames();
print('Contacts loaded, checking targetChatId: ${widget.targetChatId}');
@ -404,10 +459,13 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
Widget _buildSearchResultTile(Contact contact, {required bool isChat}) {
final localName = _localFullNames[contact.id];
final phoneBookName = _phoneBookNames[contact.id];
final displayName = (localName != null && localName.isNotEmpty)
? localName
: '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'
.trim();
: (phoneBookName != null && phoneBookName.isNotEmpty)
? phoneBookName
: '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'
.trim();
final title = displayName.isNotEmpty ? displayName : contact.username;
final subtitle = contact.username.isNotEmpty
? '@${contact.username}'
@ -560,10 +618,13 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
final isSelected = _selectedContact?.id == contact.id;
final localName = _localFullNames[contact.id];
final phoneBookName = _phoneBookNames[contact.id];
final displayName = (localName != null && localName.isNotEmpty)
? localName
: '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'
.trim();
: (phoneBookName != null && phoneBookName.isNotEmpty)
? phoneBookName
: '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'
.trim();
final contactInitials = displayName.isNotEmpty
? displayName
@ -952,10 +1013,8 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
SafeArea(top: false, child: _buildUpdateBanner(isPhone)),
],
),
floatingActionButton: null,
/* (isCollapsed || (isPhone && _currentIndex != 0))
? null
: AnimatedPadding(
floatingActionButton: (Platform.isAndroid && isPhone && _currentIndex == 0)
? AnimatedPadding(
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
padding: EdgeInsets.only(
@ -972,7 +1031,8 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
),
child: const Icon(Icons.edit_note_rounded),
),
),*/
)
: null,
bottomNavigationBar: isPhone
? BottomNavigationBar(
currentIndex: _currentIndex,
@ -1825,6 +1885,20 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
}
}
if (data['type'] == 'message_deleted') {
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
if (messageId != null) {
final contactIndex = contactProvider.contacts.indexWhere(
(c) => c.lastMessageId == messageId,
);
if (contactIndex != -1) {
final contactId = contactProvider.contacts[contactIndex].id;
await contactProvider.refreshContactLastMessage(contactId);
}
}
return;
}
if (data['type'] == 'user_updated') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null) {

View File

@ -1,8 +1,14 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart' as fc;
import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart';
import '/logic/contact_provider.dart';
import 'package:share_plus/share_plus.dart';
import '/logic/auth_provider.dart';
import '/data/models/contact_model.dart';
import '/data/repositories/contact_repository.dart';
import 'chat_screen.dart';
import 'package:cached_network_image/cached_network_image.dart';
class NewChatScreen extends StatefulWidget {
const NewChatScreen({super.key});
@ -12,52 +18,493 @@ class NewChatScreen extends StatefulWidget {
}
class _NewChatScreenState extends State<NewChatScreen> {
bool _isLoading = true;
bool _permissionDenied = false;
String? _error;
List<Map<String, dynamic>> _registeredMatches = [];
List<fc.Contact> _unregisteredMatches = [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final authProvider = context.read<AuthProvider>();
final contactProvider = context.read<ContactProvider>();
// Установить текущего пользователя и загрузить все контакты
contactProvider.setCurrentUserId(authProvider.currentUserId);
contactProvider.loadAllContactsForNewChat();
_requestPermissionAndLoad();
}
String _normalizePhone(String p) {
final digits = p.replaceAll(RegExp(r'\D'), '');
if (digits.length >= 10) {
return digits.substring(digits.length - 10);
}
return digits;
}
Future<void> _requestPermissionAndLoad() async {
setState(() {
_isLoading = true;
_permissionDenied = false;
_error = null;
});
try {
// 1. Проверяем разрешение на доступ к контактам (запрашиваем при необходимости)
bool permission = await fc.FlutterContacts.requestPermission(readonly: true);
if (!permission) {
setState(() {
_permissionDenied = true;
_isLoading = false;
});
return;
}
// 2. Разрешение получено, загружаем контакты устройства
final deviceContacts = await fc.FlutterContacts.getContacts(withProperties: true);
// 3. Загружаем список зарегистрированных пользователей из нашего репозитория
final contactRepository = ContactRepository();
final registeredUsers = await contactRepository.fetchAllUsers();
// Диагностика для отладки сопоставления контактов
debugPrint('--- NEW CHAT CONTACT MATCHING DEBUG ---');
debugPrint('Total registered users from server: ${registeredUsers.length}');
for (var u in registeredUsers) {
debugPrint(' Server User: ID=${u.id}, Username=${u.username}, Phone="${u.phone}"');
}
debugPrint('Total device contacts: ${deviceContacts.length}');
for (var dc in deviceContacts) {
final phonesStr = dc.phones.map((p) => '${p.number} (norm: ${_normalizePhone(p.number)})').join(', ');
debugPrint(' Device Contact: Name="${dc.displayName}", Phones=[$phonesStr]');
}
debugPrint('---------------------------------------');
// 4. Группируем и сопоставляем контакты по последним 10 цифрам номера телефона
final Map<String, Contact> registeredMap = {};
for (var u in registeredUsers) {
if (u.phone != null) {
final norm = _normalizePhone(u.phone!);
if (norm.isNotEmpty) {
registeredMap[norm] = u;
}
}
}
final List<Map<String, dynamic>> regMatches = [];
final List<fc.Contact> unregMatches = [];
for (var dc in deviceContacts) {
bool matched = false;
for (var phoneObj in dc.phones) {
final norm = _normalizePhone(phoneObj.number);
if (norm.isNotEmpty && registeredMap.containsKey(norm)) {
final regUser = registeredMap[norm]!;
regMatches.add({
'registered': regUser,
'device': dc,
});
matched = true;
break;
}
}
if (!matched && dc.phones.isNotEmpty) {
unregMatches.add(dc);
}
}
// Сортировка по имени в алфавитном порядке
regMatches.sort((a, b) => (a['device'] as fc.Contact).displayName.toLowerCase().compareTo((b['device'] as fc.Contact).displayName.toLowerCase()));
unregMatches.sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
setState(() {
_registeredMatches = regMatches;
_unregisteredMatches = unregMatches;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _sendInvitation(String phoneNumber) async {
final message = 'Привет! Присоединяйся к бета-тесту мессенджера Чепухаграм. Напиши разработчику в Telegram @ArturKarasevich для получения доступа!';
final uri = Uri.parse('sms:$phoneNumber?body=${Uri.encodeComponent(message)}');
try {
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
throw Exception('Не удалось открыть SMS приложение');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка отправки: $e')),
);
}
}
}
Future<void> _sendNativeShare(String name) async {
final message = 'Привет! Присоединяйся к бета-тесту мессенджера Чепухаграм. Напиши разработчику в Telegram @ArturKarasevich для получения доступа!';
try {
await Share.share(
message,
subject: 'Приглашение в Чепухаграм',
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка отправки: $e')),
);
}
}
}
Future<void> _showInviteDialog(String name, String phone) async {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text('Пригласить $name', style: const TextStyle(fontWeight: FontWeight.bold)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Этого человека ещё нет в Чепухаграм. Выберите способ приглашения:',
style: TextStyle(color: Colors.grey, fontSize: 14),
),
const SizedBox(height: 20),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(Icons.sms_rounded, color: colorScheme.primary, size: 20),
),
title: const Text('Отправить SMS', style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(phone, style: const TextStyle(fontSize: 12)),
onTap: () {
Navigator.pop(context);
_sendInvitation(phone);
},
),
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(Icons.share_rounded, color: colorScheme.primary, size: 20),
),
title: const Text('Другие мессенджеры', style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text('Нативное меню выбора (Telegram, WhatsApp и др.)', style: TextStyle(fontSize: 12)),
onTap: () {
Navigator.pop(context);
_sendNativeShare(name);
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
],
),
);
}
Widget _buildHeader(String title, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: colorScheme.primary,
letterSpacing: 1.0,
),
),
);
}
Widget _buildList(ColorScheme colorScheme) {
if (_registeredMatches.isEmpty && _unregisteredMatches.isEmpty) {
return const Center(child: Text('Контакты в телефонной книге не найдены.'));
}
final List<Widget> listItems = [];
if (_registeredMatches.isNotEmpty) {
listItems.add(_buildHeader('Контакты в Чепухаграм', colorScheme));
for (int i = 0; i < _registeredMatches.length; i++) {
final m = _registeredMatches[i];
final Contact regUser = m['registered'];
final fc.Contact devContact = m['device'];
final String dispName = devContact.displayName.isNotEmpty
? devContact.displayName
: regUser.name;
final initials = dispName.isNotEmpty
? dispName
.trim()
.split(RegExp(r'\s+'))
.take(2)
.map((e) => e.isNotEmpty ? e[0].toUpperCase() : '')
.join()
: '?';
listItems.add(
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer,
),
child: ClipOval(
child: Stack(
alignment: Alignment.center,
children: [
Text(
initials,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
),
if (regUser.effectiveAvatarUrl != null && regUser.effectiveAvatarUrl!.isNotEmpty)
CachedNetworkImage(
imageUrl: regUser.effectiveAvatarUrl!,
fit: BoxFit.cover,
width: 40,
height: 40,
placeholder: (context, url) => const SizedBox.shrink(),
errorWidget: (context, url, error) => const SizedBox.shrink(),
),
],
),
),
),
title: Text(
dispName,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
subtitle: Text(
'@${regUser.username}${regUser.phone ?? ''}',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 13),
),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'В Чепухаграм',
style: TextStyle(color: Colors.green, fontSize: 11, fontWeight: FontWeight.bold),
),
),
onTap: () {
// Открываем чат с этим контактом
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => ChatScreen(contact: regUser),
),
);
},
),
);
if (i < _registeredMatches.length - 1) {
listItems.add(
Divider(
height: 1,
indent: 72,
endIndent: 16,
color: colorScheme.outlineVariant.withOpacity(0.15),
),
);
}
}
listItems.add(const SizedBox(height: 16));
}
if (_unregisteredMatches.isNotEmpty) {
listItems.add(_buildHeader('Пригласить в Чепухаграм', colorScheme));
for (int i = 0; i < _unregisteredMatches.length; i++) {
final dc = _unregisteredMatches[i];
final phoneNum = dc.phones.isNotEmpty ? dc.phones.first.number : '';
final initials = dc.displayName.isNotEmpty
? dc.displayName
.trim()
.split(RegExp(r'\s+'))
.take(2)
.map((e) => e.isNotEmpty ? e[0].toUpperCase() : '')
.join()
: '?';
listItems.add(
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: CircleAvatar(
backgroundColor: colorScheme.surfaceVariant.withOpacity(0.4),
foregroundColor: colorScheme.outline,
child: Text(
initials,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
title: Text(
dc.displayName,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
subtitle: Text(
phoneNum,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 13),
),
trailing: OutlinedButton(
onPressed: () => _showInviteDialog(dc.displayName, phoneNum),
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.primary,
side: BorderSide(color: colorScheme.primary.withOpacity(0.5)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('Пригласить', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
),
onTap: () => _showInviteDialog(dc.displayName, phoneNum),
),
);
if (i < _unregisteredMatches.length - 1) {
listItems.add(
Divider(
height: 1,
indent: 72,
endIndent: 16,
color: colorScheme.outlineVariant.withOpacity(0.15),
),
);
}
}
}
return ListView(
physics: const BouncingScrollPhysics(),
children: listItems,
);
}
@override
Widget build(BuildContext context) {
final contactProvider = context.watch<ContactProvider>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colorScheme.background,
appBar: AppBar(
title: const Text('Новый чат'),
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
iconTheme: IconThemeData(color: colorScheme.onBackground),
title: Text(
'Новый чат',
style: TextStyle(
color: colorScheme.onBackground,
fontWeight: FontWeight.w800,
fontSize: 24,
letterSpacing: -0.5,
),
),
centerTitle: false,
),
body: contactProvider.isLoading
body: _isLoading
? const Center(child: CircularProgressIndicator())
: contactProvider.error != null
? Center(child: Text('Error: ${contactProvider.error}'))
: ListView.builder(
itemCount: contactProvider.allContacts.length,
itemBuilder: (context, index) {
final contact = contactProvider.allContacts[index];
return ListTile(
leading: CircleAvatar(
child: Text(contact.name[0]),
),
title: Text(contact.name),
onTap: () {
// Создать чат с этим контактом
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatScreen(contact: contact),
: _permissionDenied
? Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.contacts_rounded,
size: 80,
color: colorScheme.outline.withOpacity(0.4),
),
const SizedBox(height: 24),
const Text(
'Для поиска контактов необходимо разрешение',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
const Text(
'Чепухаграм сопоставит номера из вашей телефонной книги, чтобы вы могли общаться с друзьями.',
style: TextStyle(color: Colors.grey, fontSize: 14),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _requestPermissionAndLoad,
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
);
},
);
},
),
child: const Text('Предоставить доступ', style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
),
)
: _error != null
? Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline_rounded, size: 60, color: colorScheme.error),
const SizedBox(height: 16),
Text('Ошибка: $_error', textAlign: TextAlign.center),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _requestPermissionAndLoad,
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
child: const Text('Повторить попытку', style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
),
)
: _buildList(colorScheme),
);
}
}

View File

@ -87,12 +87,6 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
await _savePreference(_showAvatarKey, _showAvatar);
await _savePreference(_showAboutKey, _showAbout);
await _savePreference(_showLastOnlineKey, _showLastOnline);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Настройки видимости сохранены'), behavior: SnackBarBehavior.floating),
);
}
}
} catch (e) {
if (mounted) {
@ -109,28 +103,19 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colorScheme.background,
appBar: AppBar(
title: Text('Видимость данных', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)),
elevation: 0,
backgroundColor: Colors.transparent,
iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Center(
child: _isSaving
? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary))
: TextButton.icon(
onPressed: _saveToServer,
icon: const Icon(Icons.save_rounded, size: 18),
label: const Text('Сохранить'),
),
),
),
],
),
return WillPopScope(
onWillPop: () async {
await _saveToServer();
return true;
},
child: Scaffold(
backgroundColor: colorScheme.background,
appBar: AppBar(
title: Text('Видимость данных', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)),
elevation: 0,
backgroundColor: Colors.transparent,
iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
),
body: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(16),
@ -182,8 +167,9 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
),
],
),
);
}
),
);
}
Widget _buildSwitchTile(String title, bool value, ValueChanged<bool> onChanged) {
return SwitchListTile(

View File

@ -556,7 +556,7 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Обновить ключ шифрования'),
: const Text('Обновить пароль шифрования'),
),
),
],

View File

@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import '/core/constants.dart';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_contacts/flutter_contacts.dart' as fc;
class UserProfileScreen extends StatefulWidget {
final int userId;
@ -35,6 +36,7 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
Timer? _onlineTimer;
String? firstName;
String? lastName;
String? _phoneBookName;
bool _isAvatarExpanded = false;
@override
@ -62,9 +64,45 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
final prefs = await SharedPreferences.getInstance();
firstName = prefs.getString('firstname_${widget.userId}');
lastName = prefs.getString('lastname_${widget.userId}');
String? phoneBookName;
final phone = data['phone']?.toString();
if ((firstName == null && lastName == null) &&
phone != null &&
(Platform.isAndroid || Platform.isIOS)) {
try {
final hasPermission = await fc.FlutterContacts.requestPermission(readonly: true);
if (hasPermission) {
final deviceContacts = await fc.FlutterContacts.getContacts(withProperties: true);
String normalizePhone(String p) {
final digits = p.replaceAll(RegExp(r'\D'), '');
if (digits.length >= 10) {
return digits.substring(digits.length - 10);
}
return digits;
}
final normTarget = normalizePhone(phone);
if (normTarget.isNotEmpty) {
for (var dc in deviceContacts) {
for (var phoneObj in dc.phones) {
if (normalizePhone(phoneObj.number) == normTarget) {
phoneBookName = dc.displayName;
break;
}
}
if (phoneBookName != null) break;
}
}
}
} catch (e) {
print("Ошибка получения имени контакта из телефонной книги в профиле: $e");
}
}
if (mounted) {
setState(() {
_userData = data;
_phoneBookName = phoneBookName;
_isLoading = false;
});
}
@ -211,8 +249,10 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
if (_userData == null) return const SizedBox.shrink();
final colorScheme = Theme.of(context).colorScheme;
final String displayFN = firstName ?? _userData?['first_name'] ?? '';
final String displayLN = lastName ?? _userData?['last_name'] ?? '';
final String displayFN = firstName ?? _phoneBookName ?? _userData?['first_name'] ?? '';
final String displayLN = (firstName == null && _phoneBookName != null)
? ''
: (lastName ?? _userData?['last_name'] ?? '');
final String combinedName = '$displayFN $displayLN'.trim();
final String username = _userData?['username'] ?? '';
final rawAvatarUrl = _userData?['avatar_url']?.toString();

View File

@ -74,6 +74,7 @@ class _MessageBubbleState extends State<MessageBubble> {
Future<void>? _delayFuture;
TextSelection? _currentSelection;
TapDownDetails? _lastTapDownDetails;
final MediaCacheManager _mediaCache = MediaCacheManager();
@ -737,7 +738,16 @@ class _MessageBubbleState extends State<MessageBubble> {
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
onTapDown: (details) {
_lastTapDownDetails = details;
},
onTap: () {
if (_lastTapDownDetails != null) {
_showContextMenu(_lastTapDownDetails!);
} else {
widget.onTap?.call();
}
},
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),

View File

@ -25,6 +25,7 @@ import package_info_plus
import path_provider_foundation
import photo_manager
import record_macos
import share_plus
import shared_preferences_foundation
import sqflite_darwin
import sqlite3_flutter_libs
@ -53,6 +54,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "PhotoManagerPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))

View File

@ -638,6 +638,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_contacts:
dependency: "direct main"
description:
name: flutter_contacts
sha256: "388d32cd33f16640ee169570128c933b45f3259bddbfae7a100bb49e5ffea9ae"
url: "https://pub.dev"
source: hosted
version: "1.1.9+2"
flutter_image_compress:
dependency: "direct main"
description:
@ -1164,10 +1172,10 @@ packages:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
version: "1.0.6"
mobile_scanner:
dependency: "direct main"
description:
@ -1520,6 +1528,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.8"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: "3ef39599b00059db0990ca2e30fca0a29d8b37aae924d60063f8e0184cf20900"
url: "https://pub.dev"
source: hosted
version: "7.2.2"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496"
url: "https://pub.dev"
source: hosted
version: "3.4.0"
shared_preferences:
dependency: "direct main"
description:

View File

@ -87,6 +87,8 @@ dependencies:
scrollable_positioned_list: ^0.3.8
qr_flutter: ^4.1.0
mobile_scanner: ^7.2.0
flutter_contacts: ^1.1.6
share_plus: ^7.2.1
dev_dependencies:
flutter_test:

View File

@ -216,6 +216,7 @@ async def get_privacy_settings(current_user: models.User = Depends(get_current_u
@usersRouter.get("/all")
async def read_users_all(
request: Request,
current_user: models.User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
query: Optional[str] = None,
@ -233,7 +234,18 @@ async def read_users_all(
users_for_return.append(user)
else:
users_for_return = users
return [{"id": user.id, "username": user.username, "name": f"{user.first_name} {user.last_name or ''}".strip(), "public_key": user.public_key} for user in users_for_return]
return [
{
"id": user.id,
"username": user.username,
"name": f"{user.first_name} {user.last_name or ''}".strip(),
"public_key": user.public_key,
"phone": user.phone,
"avatar_file_id": user.avatar_file_id if (user.show_avatar or current_user.id == 1) else None,
"avatar_url": str(request.url_for("get_file", file_id=user.avatar_file_id)) if (user.show_avatar or current_user.id == 1) and user.avatar_file_id else None,
}
for user in users_for_return
]
@usersRouter.get("/chats")
async def read_users_chats(
@ -296,6 +308,7 @@ async def read_users_chats(
"online": str(user.id) in connection_manager.manager.online_users,
"last_message_id": last_msg.id if last_msg else None,
"last_message_type": last_msg.message_type if last_msg else None,
"phone": user.phone,
}
)

View File

@ -16,6 +16,7 @@
#include <local_auth_windows/local_auth_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <record_windows/record_windows_plugin_c_api.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <video_player_win/video_player_win_plugin_c_api.h>
@ -41,6 +42,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(

View File

@ -13,6 +13,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
local_auth_windows
permission_handler_windows
record_windows
share_plus
sqlite3_flutter_libs
url_launcher_windows
video_player_win