feat: кнопка добавить контакт
This commit is contained in:
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
|
|
||||||
import '../../core/config/debug_test.dart';
|
import '../../core/config/debug_test.dart';
|
||||||
import '../../core/protocol/opcode_map.dart';
|
import '../../core/protocol/opcode_map.dart';
|
||||||
|
import '../../core/protocol/packet.dart';
|
||||||
import '../../core/storage/app_database.dart';
|
import '../../core/storage/app_database.dart';
|
||||||
import '../../core/utils/logger.dart';
|
import '../../core/utils/logger.dart';
|
||||||
import '../api.dart';
|
import '../api.dart';
|
||||||
@@ -79,6 +80,15 @@ class ContactPhotos {
|
|||||||
static const empty = ContactPhotos(urls: [], total: 0);
|
static const empty = ContactPhotos(urls: [], total: 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AddContactStatus { added, notFound, error }
|
||||||
|
|
||||||
|
class AddContactResult {
|
||||||
|
final AddContactStatus status;
|
||||||
|
final CachedContact? contact;
|
||||||
|
|
||||||
|
const AddContactResult(this.status, {this.contact});
|
||||||
|
}
|
||||||
|
|
||||||
class ContactsModule {
|
class ContactsModule {
|
||||||
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
|
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
|
||||||
|
|
||||||
@@ -174,6 +184,63 @@ class ContactsModule {
|
|||||||
return CachedContact.fromDbRow(row);
|
return CachedContact.fromDbRow(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<AddContactResult> addContactByPhone(
|
||||||
|
Api api, {
|
||||||
|
required String phone,
|
||||||
|
required String firstName,
|
||||||
|
String lastName = '',
|
||||||
|
}) async {
|
||||||
|
final normalized = _normalizePhone(phone);
|
||||||
|
if (normalized == null) {
|
||||||
|
return const AddContactResult(AddContactStatus.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Packet resp;
|
||||||
|
try {
|
||||||
|
resp = await api.sendRequest(Opcode.contactAddByPhone, {
|
||||||
|
'phone': normalized,
|
||||||
|
'firstName': firstName,
|
||||||
|
'lastName': lastName,
|
||||||
|
}, silent: true);
|
||||||
|
} on PacketError catch (e) {
|
||||||
|
final key = e.errorKey ?? '';
|
||||||
|
final notFound =
|
||||||
|
key == 'user.not.found' ||
|
||||||
|
e.message.toLowerCase().contains('not found');
|
||||||
|
return AddContactResult(
|
||||||
|
notFound ? AddContactStatus.notFound : AddContactStatus.error,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return const AddContactResult(AddContactStatus.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = resp.payload;
|
||||||
|
final contact = (data is Map && data['contact'] is Map)
|
||||||
|
? (data['contact'] as Map).cast<dynamic, dynamic>()
|
||||||
|
: null;
|
||||||
|
if (contact == null) {
|
||||||
|
return const AddContactResult(AddContactStatus.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
final profile = await AppDatabase.loadActiveProfile();
|
||||||
|
if (profile == null) {
|
||||||
|
return const AddContactResult(AddContactStatus.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
final row = _parseContact(contact, profile.id);
|
||||||
|
if (row == null) {
|
||||||
|
return const AddContactResult(AddContactStatus.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await AppDatabase.saveContacts([row]);
|
||||||
|
primeContactCache(contact);
|
||||||
|
revision.value++;
|
||||||
|
return AddContactResult(
|
||||||
|
AddContactStatus.added,
|
||||||
|
contact: CachedContact.fromDbRow(row),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> syncFromLoginPayload(
|
static Future<void> syncFromLoginPayload(
|
||||||
Map<dynamic, dynamic> data,
|
Map<dynamic, dynamic> data,
|
||||||
int accountId,
|
int accountId,
|
||||||
@@ -230,11 +297,8 @@ class ContactsModule {
|
|||||||
|
|
||||||
final names = contact['names'];
|
final names = contact['names'];
|
||||||
if (names is List && names.isNotEmpty) {
|
if (names is List && names.isNotEmpty) {
|
||||||
final nameRaw = names.firstWhere(
|
final nameRaw = _preferredNameEntry(names);
|
||||||
(n) => n is Map && n['type'] == 'ONEME',
|
if (nameRaw != null) {
|
||||||
orElse: () => names.firstWhere((n) => n is Map, orElse: () => null),
|
|
||||||
);
|
|
||||||
if (nameRaw is Map) {
|
|
||||||
final firstName = (nameRaw['firstName'] as String?) ?? '';
|
final firstName = (nameRaw['firstName'] as String?) ?? '';
|
||||||
final lastName = nameRaw['lastName'] as String?;
|
final lastName = nameRaw['lastName'] as String?;
|
||||||
final fullName = (lastName != null && lastName.isNotEmpty)
|
final fullName = (lastName != null && lastName.isNotEmpty)
|
||||||
@@ -326,6 +390,19 @@ class ContactsModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Map? _preferredNameEntry(List names) {
|
||||||
|
Map? oneme;
|
||||||
|
Map? any;
|
||||||
|
for (final n in names) {
|
||||||
|
if (n is! Map) continue;
|
||||||
|
any ??= n;
|
||||||
|
final type = n['type'];
|
||||||
|
if (type == 'CUSTOM') return n;
|
||||||
|
if (type == 'ONEME') oneme ??= n;
|
||||||
|
}
|
||||||
|
return oneme ?? any;
|
||||||
|
}
|
||||||
|
|
||||||
static Map<String, dynamic>? _parseContact(
|
static Map<String, dynamic>? _parseContact(
|
||||||
Map<dynamic, dynamic> contact,
|
Map<dynamic, dynamic> contact,
|
||||||
int accountId,
|
int accountId,
|
||||||
@@ -338,14 +415,10 @@ class ContactsModule {
|
|||||||
|
|
||||||
final names = contact['names'];
|
final names = contact['names'];
|
||||||
if (names is List && names.isNotEmpty) {
|
if (names is List && names.isNotEmpty) {
|
||||||
final nameRaw = names.firstWhere(
|
final nameRaw = _preferredNameEntry(names);
|
||||||
(n) => n is Map && n['type'] == 'ONEME',
|
if (nameRaw == null) return null;
|
||||||
orElse: () => names.firstWhere((n) => n is Map, orElse: () => null),
|
firstName = (nameRaw['firstName'] as String?) ?? '';
|
||||||
);
|
lastName = nameRaw['lastName'] as String?;
|
||||||
if (nameRaw is! Map) return null;
|
|
||||||
final name = nameRaw;
|
|
||||||
firstName = (name['firstName'] as String?) ?? '';
|
|
||||||
lastName = name['lastName'] as String?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final optionsRaw = contact['options'];
|
final optionsRaw = contact['options'];
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ abstract class Opcode {
|
|||||||
static const int contactMutual = 38; // Общие контакты
|
static const int contactMutual = 38; // Общие контакты
|
||||||
static const int contactPhotos = 39; // Фото контакта
|
static const int contactPhotos = 39; // Фото контакта
|
||||||
static const int contactSort = 40; // Сортировка контактов
|
static const int contactSort = 40; // Сортировка контактов
|
||||||
|
static const int contactAddByPhone = 41; // Добавление контакта по номеру
|
||||||
static const int contactVerify = 42; // Верификация контакта
|
static const int contactVerify = 42; // Верификация контакта
|
||||||
static const int removeContactPhoto = 43; // Удаление фото контакта
|
static const int removeContactPhoto = 43; // Удаление фото контакта
|
||||||
static const int contactInfoByPhone = 46; // Поиск контакта по номеру
|
static const int contactInfoByPhone = 46; // Поиск контакта по номеру
|
||||||
@@ -270,6 +271,7 @@ abstract class Opcode {
|
|||||||
contactMutual: 'CONTACT_MUTUAL',
|
contactMutual: 'CONTACT_MUTUAL',
|
||||||
contactPhotos: 'CONTACT_PHOTOS',
|
contactPhotos: 'CONTACT_PHOTOS',
|
||||||
contactSort: 'CONTACT_SORT',
|
contactSort: 'CONTACT_SORT',
|
||||||
|
contactAddByPhone: 'CONTACT_ADD_BY_PHONE',
|
||||||
contactVerify: 'CONTACT_VERIFY',
|
contactVerify: 'CONTACT_VERIFY',
|
||||||
removeContactPhoto: 'REMOVE_CONTACT_PHOTO',
|
removeContactPhoto: 'REMOVE_CONTACT_PHOTO',
|
||||||
contactInfoByPhone: 'CONTACT_INFO_BY_PHONE',
|
contactInfoByPhone: 'CONTACT_INFO_BY_PHONE',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import 'code_confirmation_screen.dart';
|
import 'code_confirmation_screen.dart';
|
||||||
import 'token_login_screen.dart';
|
import 'token_login_screen.dart';
|
||||||
import 'select_country_screen.dart';
|
import 'select_country_screen.dart';
|
||||||
|
import 'phone_input_formatter.dart';
|
||||||
import 'proxy_settings_sheet.dart';
|
import 'proxy_settings_sheet.dart';
|
||||||
import 'server_settings_sheet.dart';
|
import 'server_settings_sheet.dart';
|
||||||
import '../profile/spoof_screen.dart';
|
import '../profile/spoof_screen.dart';
|
||||||
@@ -873,7 +874,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
_PhoneInputFormatter(_selectedCountry),
|
PhoneInputFormatter(_selectedCountry),
|
||||||
],
|
],
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: cs.onSurface,
|
color: cs.onSurface,
|
||||||
@@ -1068,60 +1069,3 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PhoneInputFormatter extends TextInputFormatter {
|
|
||||||
final CountryName country;
|
|
||||||
_PhoneInputFormatter(this.country);
|
|
||||||
|
|
||||||
@override
|
|
||||||
TextEditingValue formatEditUpdate(
|
|
||||||
TextEditingValue oldValue,
|
|
||||||
TextEditingValue newValue,
|
|
||||||
) {
|
|
||||||
var text = newValue.text.replaceAll(RegExp(r'\D'), '');
|
|
||||||
|
|
||||||
if (newValue.text.length < oldValue.text.length) {
|
|
||||||
final oldDigits = oldValue.text.replaceAll(RegExp(r'\D'), '');
|
|
||||||
if (text.length == oldDigits.length && text.isNotEmpty) {
|
|
||||||
text = text.substring(0, text.length - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text.length > country.phoneDigits) {
|
|
||||||
text = text.substring(0, country.phoneDigits);
|
|
||||||
}
|
|
||||||
|
|
||||||
final buffer = StringBuffer();
|
|
||||||
int digitIdx = 0;
|
|
||||||
|
|
||||||
for (int i = 0; i < country.phoneGroupSizes.length; i++) {
|
|
||||||
if (digitIdx >= text.length) break;
|
|
||||||
|
|
||||||
buffer.write(country.phoneGroupSeparators[i]);
|
|
||||||
|
|
||||||
final groupSize = country.phoneGroupSizes[i];
|
|
||||||
final remainingDigits = text.length - digitIdx;
|
|
||||||
final digitsToTake = remainingDigits < groupSize
|
|
||||||
? remainingDigits
|
|
||||||
: groupSize;
|
|
||||||
|
|
||||||
buffer.write(text.substring(digitIdx, digitIdx + digitsToTake));
|
|
||||||
digitIdx += digitsToTake;
|
|
||||||
|
|
||||||
if (digitIdx == text.length &&
|
|
||||||
i < country.phoneGroupSeparators.length - 1) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (digitIdx == text.length && text.length == country.phoneDigits) {
|
|
||||||
if (country.phoneGroupSeparators.length >
|
|
||||||
country.phoneGroupSizes.length) {
|
|
||||||
buffer.write(country.phoneGroupSeparators.last);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final formattedText = buffer.toString();
|
|
||||||
return TextEditingValue(
|
|
||||||
text: formattedText,
|
|
||||||
selection: TextSelection.collapsed(offset: formattedText.length),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../../../core/config/countries.dart';
|
||||||
|
|
||||||
|
class PhoneInputFormatter extends TextInputFormatter {
|
||||||
|
final CountryName country;
|
||||||
|
PhoneInputFormatter(this.country);
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
var text = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||||
|
|
||||||
|
if (newValue.text.length < oldValue.text.length) {
|
||||||
|
final oldDigits = oldValue.text.replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (text.length == oldDigits.length && text.isNotEmpty) {
|
||||||
|
text = text.substring(0, text.length - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length > country.phoneDigits) {
|
||||||
|
text = text.substring(0, country.phoneDigits);
|
||||||
|
}
|
||||||
|
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
int digitIdx = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < country.phoneGroupSizes.length; i++) {
|
||||||
|
if (digitIdx >= text.length) break;
|
||||||
|
|
||||||
|
buffer.write(country.phoneGroupSeparators[i]);
|
||||||
|
|
||||||
|
final groupSize = country.phoneGroupSizes[i];
|
||||||
|
final remainingDigits = text.length - digitIdx;
|
||||||
|
final digitsToTake = remainingDigits < groupSize
|
||||||
|
? remainingDigits
|
||||||
|
: groupSize;
|
||||||
|
|
||||||
|
buffer.write(text.substring(digitIdx, digitIdx + digitsToTake));
|
||||||
|
digitIdx += digitsToTake;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digitIdx == text.length && text.length == country.phoneDigits) {
|
||||||
|
if (country.phoneGroupSeparators.length >
|
||||||
|
country.phoneGroupSizes.length) {
|
||||||
|
buffer.write(country.phoneGroupSeparators.last);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final formattedText = buffer.toString();
|
||||||
|
return TextEditingValue(
|
||||||
|
text: formattedText,
|
||||||
|
selection: TextSelection.collapsed(offset: formattedText.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import 'package:flutter/gestures.dart';
|
|||||||
import 'chat_screen.dart';
|
import 'chat_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
import 'create_group_flow.dart';
|
import 'create_group_flow.dart';
|
||||||
|
import '../contacts/add_contact_sheet.dart';
|
||||||
import '../../widgets/adaptive_shell.dart';
|
import '../../widgets/adaptive_shell.dart';
|
||||||
import '../../widgets/online_dot.dart';
|
import '../../widgets/online_dot.dart';
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
@@ -2835,7 +2836,14 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
_buildFabMenuItem(Symbols.campaign, 'Создать канал'),
|
_buildFabMenuItem(Symbols.campaign, 'Создать канал'),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
_buildFabMenuItem(Symbols.person_add, 'Создать контакт'),
|
_buildFabMenuItem(
|
||||||
|
Symbols.person_add,
|
||||||
|
'Создать контакт',
|
||||||
|
onTap: () {
|
||||||
|
_toggleFab();
|
||||||
|
showAddContactSheet(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:komet/backend/modules/contacts.dart';
|
||||||
|
import 'package:komet/core/config/countries.dart';
|
||||||
|
import 'package:komet/frontend/screens/auth/phone_input_formatter.dart';
|
||||||
|
import 'package:komet/frontend/screens/auth/select_country_screen.dart';
|
||||||
|
import 'package:komet/frontend/screens/contacts/open_contact_profile.dart';
|
||||||
|
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||||
|
import 'package:komet/l10n/app_localizations.dart';
|
||||||
|
import 'package:komet/main.dart';
|
||||||
|
|
||||||
|
Future<void> showAddContactSheet(BuildContext context) {
|
||||||
|
return showGeneralDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: true,
|
||||||
|
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
||||||
|
barrierColor: Colors.black.withValues(alpha: 0.28),
|
||||||
|
transitionDuration: const Duration(milliseconds: 260),
|
||||||
|
pageBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||||
|
transitionBuilder: (dialogContext, anim, _, _) {
|
||||||
|
final t = Curves.easeOutCubic.transform(anim.value);
|
||||||
|
return BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 14 * t, sigmaY: 14 * t),
|
||||||
|
child: Opacity(
|
||||||
|
opacity: anim.value,
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: 0.94 + 0.06 * t,
|
||||||
|
child: _AddContactCard(hostContext: context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddContactCard extends StatefulWidget {
|
||||||
|
final BuildContext hostContext;
|
||||||
|
|
||||||
|
const _AddContactCard({required this.hostContext});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AddContactCard> createState() => _AddContactCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddContactCardState extends State<_AddContactCard> {
|
||||||
|
final TextEditingController _phoneCtrl = TextEditingController();
|
||||||
|
final TextEditingController _firstCtrl = TextEditingController();
|
||||||
|
final TextEditingController _lastCtrl = TextEditingController();
|
||||||
|
|
||||||
|
late CountryName _country;
|
||||||
|
bool _loading = false;
|
||||||
|
String? _notFoundPhone;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final allowed = api.registrationCountries;
|
||||||
|
_country =
|
||||||
|
countriesByCode['RU'] ??
|
||||||
|
(allowed.isNotEmpty ? allowed.first : allCountries.first);
|
||||||
|
if (allowed.isNotEmpty && !allowed.any((c) => c.code == _country.code)) {
|
||||||
|
_country = allowed.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_phoneCtrl.dispose();
|
||||||
|
_firstCtrl.dispose();
|
||||||
|
_lastCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _digits => _phoneCtrl.text.replaceAll(RegExp(r'\D'), '');
|
||||||
|
bool get _phoneValid => _digits.length == _country.phoneDigits;
|
||||||
|
bool get _canSave =>
|
||||||
|
_phoneValid && _firstCtrl.text.trim().isNotEmpty && !_loading;
|
||||||
|
|
||||||
|
Future<void> _pickCountry() async {
|
||||||
|
final picked = await Navigator.of(context).push<CountryName>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => SelectCountryScreen(
|
||||||
|
selectedCountry: _country,
|
||||||
|
countries: api.registrationCountries,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() {
|
||||||
|
_country = picked;
|
||||||
|
_phoneCtrl.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (!_canSave) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
final phone = '${_country.phoneCode}$_digits';
|
||||||
|
final result = await ContactsModule.addContactByPhone(
|
||||||
|
api,
|
||||||
|
phone: phone,
|
||||||
|
firstName: _firstCtrl.text.trim(),
|
||||||
|
lastName: _lastCtrl.text.trim(),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loading = false);
|
||||||
|
|
||||||
|
switch (result.status) {
|
||||||
|
case AddContactStatus.added:
|
||||||
|
final contact = result.contact;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
if (contact == null) return;
|
||||||
|
final name = (contact.lastName != null && contact.lastName!.isNotEmpty)
|
||||||
|
? '${contact.firstName} ${contact.lastName}'
|
||||||
|
: contact.firstName;
|
||||||
|
if (!widget.hostContext.mounted) return;
|
||||||
|
await openContactDialogProfile(
|
||||||
|
widget.hostContext,
|
||||||
|
contactId: contact.id,
|
||||||
|
name: name,
|
||||||
|
avatarUrl: contact.baseUrl,
|
||||||
|
);
|
||||||
|
case AddContactStatus.notFound:
|
||||||
|
setState(() => _notFoundPhone = phone);
|
||||||
|
case AddContactStatus.error:
|
||||||
|
showCustomNotification(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(context)!.addContactError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final width = MediaQuery.sizeOf(context).width;
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Container(
|
||||||
|
width: width > 420 ? 380 : double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
child: _notFoundPhone != null
|
||||||
|
? _buildNotFound(cs)
|
||||||
|
: _buildForm(cs),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildForm(ColorScheme cs) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.addContactTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildPhoneRow(cs),
|
||||||
|
_divider(cs),
|
||||||
|
_buildTextRow(
|
||||||
|
cs,
|
||||||
|
controller: _firstCtrl,
|
||||||
|
hint: l10n.addContactFirstName,
|
||||||
|
),
|
||||||
|
_divider(cs),
|
||||||
|
_buildTextRow(
|
||||||
|
cs,
|
||||||
|
controller: _lastCtrl,
|
||||||
|
hint: l10n.addContactLastName,
|
||||||
|
),
|
||||||
|
_divider(cs),
|
||||||
|
_buildSaveButton(cs, l10n),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPhoneRow(ColorScheme cs) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: _loading ? null : _pickCountry,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_flagEmoji(_country.code),
|
||||||
|
style: const TextStyle(fontSize: 22),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
_country.phoneCode,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.keyboard_arrow_down,
|
||||||
|
size: 20,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
key: ValueKey(_country.code),
|
||||||
|
controller: _phoneCtrl,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
PhoneInputFormatter(_country),
|
||||||
|
],
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isCollapsed: true,
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintText: _country.phoneMask.replaceAll('#', '0'),
|
||||||
|
hintStyle: TextStyle(color: cs.outline, fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextRow(
|
||||||
|
ColorScheme cs, {
|
||||||
|
required TextEditingController controller,
|
||||||
|
required String hint,
|
||||||
|
}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
maxLength: 60,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isCollapsed: true,
|
||||||
|
border: InputBorder.none,
|
||||||
|
counterText: '',
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${controller.text.characters.length}/60',
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSaveButton(ColorScheme cs, AppLocalizations l10n) {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _canSave ? _save : null,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
foregroundColor: cs.primary,
|
||||||
|
disabledForegroundColor: cs.onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
child: _loading
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: cs.primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
l10n.addContactSave,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNotFound(ColorScheme cs) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 22, 20, 12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.addContactNotFound(_notFoundPhone ?? ''),
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 21,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
l10n.addContactNotFoundSubtitle,
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: () => setState(() => _notFoundPhone = null),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
child: Text(l10n.addContactSearchOther),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _divider(ColorScheme cs) =>
|
||||||
|
Divider(height: 1, thickness: 0.5, color: cs.outlineVariant);
|
||||||
|
|
||||||
|
static String _flagEmoji(String code) {
|
||||||
|
if (code.length != 2) return '🏳️';
|
||||||
|
final upper = code.toUpperCase();
|
||||||
|
final a = upper.codeUnitAt(0);
|
||||||
|
final b = upper.codeUnitAt(1);
|
||||||
|
if (a < 65 || a > 90 || b < 65 || b > 90) return '🏳️';
|
||||||
|
return String.fromCharCode(0x1F1E6 + (a - 65)) +
|
||||||
|
String.fromCharCode(0x1F1E6 + (b - 65));
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-1
@@ -919,5 +919,20 @@
|
|||||||
"updateUpToDate": "You have the latest version",
|
"updateUpToDate": "You have the latest version",
|
||||||
"updateCheckFailed": "Couldn't check for updates. Try again later",
|
"updateCheckFailed": "Couldn't check for updates. Try again later",
|
||||||
"profileResurrecting": "Oops! The server didn't send your profile. Trying to regenerate…",
|
"profileResurrecting": "Oops! The server didn't send your profile. Trying to regenerate…",
|
||||||
"profilePhoneRegenFailed": "Couldn't regenerate your phone number. Please sign in again and report the issue to the developers"
|
"profilePhoneRegenFailed": "Couldn't regenerate your phone number. Please sign in again and report the issue to the developers",
|
||||||
|
"addContactTitle": "Add contact",
|
||||||
|
"addContactFirstName": "First name",
|
||||||
|
"addContactLastName": "Last name (optional)",
|
||||||
|
"addContactSave": "Save contact",
|
||||||
|
"addContactNotFound": "{phone} not found",
|
||||||
|
"@addContactNotFound": {
|
||||||
|
"placeholders": {
|
||||||
|
"phone": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"addContactNotFoundSubtitle": "This number isn't on the app yet",
|
||||||
|
"addContactSearchOther": "Search for other number",
|
||||||
|
"addContactError": "Couldn't add contact"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4075,6 +4075,54 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Couldn\'t regenerate your phone number. Please sign in again and report the issue to the developers'**
|
/// **'Couldn\'t regenerate your phone number. Please sign in again and report the issue to the developers'**
|
||||||
String get profilePhoneRegenFailed;
|
String get profilePhoneRegenFailed;
|
||||||
|
|
||||||
|
/// No description provided for @addContactTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add contact'**
|
||||||
|
String get addContactTitle;
|
||||||
|
|
||||||
|
/// No description provided for @addContactFirstName.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'First name'**
|
||||||
|
String get addContactFirstName;
|
||||||
|
|
||||||
|
/// No description provided for @addContactLastName.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Last name (optional)'**
|
||||||
|
String get addContactLastName;
|
||||||
|
|
||||||
|
/// No description provided for @addContactSave.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save contact'**
|
||||||
|
String get addContactSave;
|
||||||
|
|
||||||
|
/// No description provided for @addContactNotFound.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{phone} not found'**
|
||||||
|
String addContactNotFound(String phone);
|
||||||
|
|
||||||
|
/// No description provided for @addContactNotFoundSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This number isn\'t on the app yet'**
|
||||||
|
String get addContactNotFoundSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @addContactSearchOther.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search for other number'**
|
||||||
|
String get addContactSearchOther;
|
||||||
|
|
||||||
|
/// No description provided for @addContactError.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Couldn\'t add contact'**
|
||||||
|
String get addContactError;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -2122,4 +2122,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get profilePhoneRegenFailed =>
|
String get profilePhoneRegenFailed =>
|
||||||
'Couldn\'t regenerate your phone number. Please sign in again and report the issue to the developers';
|
'Couldn\'t regenerate your phone number. Please sign in again and report the issue to the developers';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContactTitle => 'Add contact';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContactFirstName => 'First name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContactLastName => 'Last name (optional)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContactSave => 'Save contact';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String addContactNotFound(String phone) {
|
||||||
|
return '$phone not found';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContactNotFoundSubtitle => 'This number isn\'t on the app yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContactSearchOther => 'Search for other number';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContactError => 'Couldn\'t add contact';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2135,4 +2135,30 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get profilePhoneRegenFailed =>
|
String get profilePhoneRegenFailed =>
|
||||||
'Не удалось регенерировать данные об номере. Перезайдите и сообщите об проблеме разработчикам';
|
'Не удалось регенерировать данные об номере. Перезайдите и сообщите об проблеме разработчикам';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContactTitle => 'Новый контакт';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContactFirstName => 'Имя';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContactLastName => 'Фамилия (необязательно)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContactSave => 'Сохранить контакт';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String addContactNotFound(String phone) {
|
||||||
|
return '$phone не найден';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContactNotFoundSubtitle => 'Этого номера пока нет в приложении';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContactSearchOther => 'Искать другой номер';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContactError => 'Не удалось добавить контакт';
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -694,5 +694,20 @@
|
|||||||
"updateUpToDate": "Установлена актуальная версия",
|
"updateUpToDate": "Установлена актуальная версия",
|
||||||
"updateCheckFailed": "Не удалось проверить обновления. Повторите позже",
|
"updateCheckFailed": "Не удалось проверить обновления. Повторите позже",
|
||||||
"profileResurrecting": "Упс! Сервер не прислал profile. Попробую регенерировать…",
|
"profileResurrecting": "Упс! Сервер не прислал profile. Попробую регенерировать…",
|
||||||
"profilePhoneRegenFailed": "Не удалось регенерировать данные об номере. Перезайдите и сообщите об проблеме разработчикам"
|
"profilePhoneRegenFailed": "Не удалось регенерировать данные об номере. Перезайдите и сообщите об проблеме разработчикам",
|
||||||
|
"addContactTitle": "Новый контакт",
|
||||||
|
"addContactFirstName": "Имя",
|
||||||
|
"addContactLastName": "Фамилия (необязательно)",
|
||||||
|
"addContactSave": "Сохранить контакт",
|
||||||
|
"addContactNotFound": "{phone} не найден",
|
||||||
|
"@addContactNotFound": {
|
||||||
|
"placeholders": {
|
||||||
|
"phone": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"addContactNotFoundSubtitle": "Этого номера пока нет в приложении",
|
||||||
|
"addContactSearchOther": "Искать другой номер",
|
||||||
|
"addContactError": "Не удалось добавить контакт"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user