feat: кнопка добавить контакт
This commit is contained in:
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/config/debug_test.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../api.dart';
|
||||
@@ -79,6 +80,15 @@ class ContactPhotos {
|
||||
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 {
|
||||
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
|
||||
|
||||
@@ -174,6 +184,63 @@ class ContactsModule {
|
||||
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(
|
||||
Map<dynamic, dynamic> data,
|
||||
int accountId,
|
||||
@@ -230,11 +297,8 @@ class ContactsModule {
|
||||
|
||||
final names = contact['names'];
|
||||
if (names is List && names.isNotEmpty) {
|
||||
final nameRaw = names.firstWhere(
|
||||
(n) => n is Map && n['type'] == 'ONEME',
|
||||
orElse: () => names.firstWhere((n) => n is Map, orElse: () => null),
|
||||
);
|
||||
if (nameRaw is Map) {
|
||||
final nameRaw = _preferredNameEntry(names);
|
||||
if (nameRaw != null) {
|
||||
final firstName = (nameRaw['firstName'] as String?) ?? '';
|
||||
final lastName = nameRaw['lastName'] as String?;
|
||||
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(
|
||||
Map<dynamic, dynamic> contact,
|
||||
int accountId,
|
||||
@@ -338,14 +415,10 @@ class ContactsModule {
|
||||
|
||||
final names = contact['names'];
|
||||
if (names is List && names.isNotEmpty) {
|
||||
final nameRaw = names.firstWhere(
|
||||
(n) => n is Map && n['type'] == 'ONEME',
|
||||
orElse: () => names.firstWhere((n) => n is Map, orElse: () => null),
|
||||
);
|
||||
if (nameRaw is! Map) return null;
|
||||
final name = nameRaw;
|
||||
firstName = (name['firstName'] as String?) ?? '';
|
||||
lastName = name['lastName'] as String?;
|
||||
final nameRaw = _preferredNameEntry(names);
|
||||
if (nameRaw == null) return null;
|
||||
firstName = (nameRaw['firstName'] as String?) ?? '';
|
||||
lastName = nameRaw['lastName'] as String?;
|
||||
}
|
||||
|
||||
final optionsRaw = contact['options'];
|
||||
|
||||
@@ -59,6 +59,7 @@ abstract class Opcode {
|
||||
static const int contactMutual = 38; // Общие контакты
|
||||
static const int contactPhotos = 39; // Фото контакта
|
||||
static const int contactSort = 40; // Сортировка контактов
|
||||
static const int contactAddByPhone = 41; // Добавление контакта по номеру
|
||||
static const int contactVerify = 42; // Верификация контакта
|
||||
static const int removeContactPhoto = 43; // Удаление фото контакта
|
||||
static const int contactInfoByPhone = 46; // Поиск контакта по номеру
|
||||
@@ -270,6 +271,7 @@ abstract class Opcode {
|
||||
contactMutual: 'CONTACT_MUTUAL',
|
||||
contactPhotos: 'CONTACT_PHOTOS',
|
||||
contactSort: 'CONTACT_SORT',
|
||||
contactAddByPhone: 'CONTACT_ADD_BY_PHONE',
|
||||
contactVerify: 'CONTACT_VERIFY',
|
||||
removeContactPhoto: 'REMOVE_CONTACT_PHOTO',
|
||||
contactInfoByPhone: 'CONTACT_INFO_BY_PHONE',
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'code_confirmation_screen.dart';
|
||||
import 'token_login_screen.dart';
|
||||
import 'select_country_screen.dart';
|
||||
import 'phone_input_formatter.dart';
|
||||
import 'proxy_settings_sheet.dart';
|
||||
import 'server_settings_sheet.dart';
|
||||
import '../profile/spoof_screen.dart';
|
||||
@@ -873,7 +874,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
_PhoneInputFormatter(_selectedCountry),
|
||||
PhoneInputFormatter(_selectedCountry),
|
||||
],
|
||||
style: TextStyle(
|
||||
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 'search_screen.dart';
|
||||
import 'create_group_flow.dart';
|
||||
import '../contacts/add_contact_sheet.dart';
|
||||
import '../../widgets/adaptive_shell.dart';
|
||||
import '../../widgets/online_dot.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
@@ -2835,7 +2836,14 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
const SizedBox(height: 4),
|
||||
_buildFabMenuItem(Symbols.campaign, 'Создать канал'),
|
||||
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",
|
||||
"updateCheckFailed": "Couldn't check for updates. Try again later",
|
||||
"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:
|
||||
/// **'Couldn\'t regenerate your phone number. Please sign in again and report the issue to the developers'**
|
||||
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
|
||||
|
||||
@@ -2122,4 +2122,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get profilePhoneRegenFailed =>
|
||||
'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
|
||||
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": "Установлена актуальная версия",
|
||||
"updateCheckFailed": "Не удалось проверить обновления. Повторите позже",
|
||||
"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