feat: изменение контакта

This commit is contained in:
Jganenokk
2026-07-23 21:03:55 +07:00
parent 147529d780
commit e39cb9dec9
13 changed files with 709 additions and 15 deletions
+78
View File
@@ -1,10 +1,12 @@
import 'package:flutter/foundation.dart';
import '../../core/cache/info_cache.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 '../../models/contact_info.dart';
import '../api.dart';
import 'messages.dart';
@@ -241,6 +243,82 @@ class ContactsModule {
);
}
static Future<CachedContact?> updateContact(
Api api, {
required int contactId,
required String firstName,
required String lastName,
}) async {
final Packet resp;
try {
resp = await api.sendRequest(Opcode.contactUpdate, {
'contactId': contactId,
'action': 'UPDATE',
'firstName': firstName,
'lastName': lastName,
});
} catch (_) {
return null;
}
final profile = await AppDatabase.loadActiveProfile();
if (profile == null) return null;
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 null;
final row = _parseContact(contact, profile.id);
if (row == null) return null;
await AppDatabase.saveContacts([row]);
primeContactCache(contact);
ContactInfoFetch.putContact(contactId, contact);
revision.value++;
return CachedContact.fromDbRow(row);
}
static Future<bool> removeContact(Api api, int contactId) async {
try {
await api.sendRequest(Opcode.contactUpdate, {
'contactId': contactId,
'action': 'REMOVE',
});
} catch (_) {
return false;
}
final profile = await AppDatabase.loadActiveProfile();
if (profile != null) {
await AppDatabase.deleteContact(profile.id, contactId);
}
ContactCache.remove(contactId);
ContactInfo? info = ContactInfoFetch.peek(contactId);
if (info == null) {
ContactInfoFetch.invalidate(contactId);
info = await ContactInfoFetch.get(contactId, forceRefresh: true);
}
final rawNames = info?.raw['names'];
if (info != null && rawNames is List) {
final stripped = rawNames
.where((n) => !(n is Map && n['type'] == 'CUSTOM'))
.toList();
final newRaw = Map<String, dynamic>.from(info.raw)..['names'] = stripped;
ContactInfoFetch.putContact(contactId, newRaw);
primeContactCache(newRaw);
} else {
ContactInfoFetch.invalidate(contactId);
}
revision.value++;
return true;
}
static Future<void> syncFromLoginPayload(
Map<dynamic, dynamic> data,
int accountId,
+8
View File
@@ -80,6 +80,14 @@ class ContactCache {
static bool isOfficial(int id) =>
_optionsCache[id]?.contains('OFFICIAL') ?? false;
static void remove(int id) {
_nameCache.remove(id);
_avatarCache.remove(id);
_optionsCache.remove(id);
_phoneCache.remove(id);
_scheduleSave();
}
static void clear() {
_nameCache.clear();
_avatarCache.clear();
+4
View File
@@ -109,6 +109,10 @@ class ContactInfoFetch {
static void invalidate(int id) => _cache.invalidate(id);
static void clear() => _cache.clear();
static void putContact(int id, Map<dynamic, dynamic> contact) {
_cache.putValue(id, ContactInfo.fromMap(Map<String, dynamic>.from(contact)));
}
static Future<ContactInfo?> _fetch(int id) async {
final api = _api;
if (api == null || api.state != SessionState.online) return null;
+9
View File
@@ -883,6 +883,15 @@ class AppDatabase {
);
}
static Future<void> deleteContact(int accountId, int id) async {
final db = await _instance;
await db.delete(
'contacts',
where: 'account_id = ? AND id = ?',
whereArgs: [accountId, id],
);
}
static Future<void> saveMessages(List<Map<String, dynamic>> rows) async {
final db = await _instance;
await db.transaction((txn) async {
@@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:komet/main.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../contacts/edit_contact_sheet.dart';
import '../../../backend/modules/messages.dart' show ContactCache;
import '../../../core/cache/info_cache.dart';
import '../../../core/config/app_show_extra_info.dart';
@@ -77,6 +78,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
String _selectedTab = '';
bool _descExpanded = false;
bool _showRealName = false;
String? _nameOverride;
int? _otherId;
ContactInfo? _contactData;
@@ -275,12 +277,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
icon: Icon(Icons.arrow_back, color: cs.onSurface),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: Icon(Icons.more_vert, color: cs.onSurface),
onPressed: () {},
),
],
actions: [_buildMoreButton(cs)],
),
SliverToBoxAdapter(child: _buildBody(cs)),
],
@@ -316,6 +313,96 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
);
}
ContactName? _nameEntry(String type) {
final data = _contactData;
if (data == null) return null;
for (final n in data.names) {
if (n.type == type) return n;
}
return null;
}
bool get _isContact => _nameEntry('CUSTOM') != null;
String _joinName(String first, String last) => last.trim().isEmpty
? first.trim()
: '${first.trim()} ${last.trim()}';
Widget _buildMoreButton(ColorScheme cs) {
final canEdit = widget.chatType == 'DIALOG' && _isContact;
if (!canEdit) {
return IconButton(
icon: Icon(Icons.more_vert, color: cs.onSurface),
onPressed: () {},
);
}
final l10n = AppLocalizations.of(context)!;
return PopupMenuButton<String>(
icon: Icon(Icons.more_vert, color: cs.onSurface),
onSelected: (v) {
if (v == 'edit') _openEdit();
},
itemBuilder: (_) => [
PopupMenuItem<String>(
value: 'edit',
child: Row(
children: [
Icon(Symbols.edit, size: 20, color: cs.onSurface),
const SizedBox(width: 12),
Text(l10n.editContactMenu),
],
),
),
],
);
}
Future<void> _openEdit() async {
final custom = _nameEntry('CUSTOM');
final oneme = _nameEntry('ONEME');
final peerId = _otherId ?? widget.dialogPeerId ?? 0;
if (peerId == 0) return;
final result = await showEditContactSheet(
context,
contactId: peerId,
avatarUrl: _contactData?.avatarUrl ?? widget.imageUrl,
customFirst: custom?.firstName ?? '',
customLast: custom?.lastName ?? '',
onemeFirst: oneme?.firstName ?? '',
onemeLast: oneme?.lastName ?? '',
);
if (!mounted || result == null) return;
switch (result.action) {
case EditContactAction.updated:
_applyUpdatedNames(result.firstName, result.lastName);
case EditContactAction.removed:
Navigator.of(context).pop();
}
}
void _applyUpdatedNames(String first, String last) {
final data = _contactData;
final newCustom = ContactName(
type: 'CUSTOM',
name: first,
firstName: first,
lastName: last,
);
final others = data == null
? <ContactName>[]
: data.names.where((n) => n.type != 'CUSTOM').toList();
setState(() {
_contactData = ContactInfo(
raw: data?.raw ?? const <String, dynamic>{},
names: [newCustom, ...others],
);
_nameOverride = _joinName(first, last);
_showRealName = false;
});
}
String? get _realName {
final data = _contactData;
if (data == null) return null;
@@ -339,19 +426,20 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
fontSize: 22,
fontWeight: FontWeight.w700,
);
final custom = _nameOverride ?? widget.name;
final real = _realName;
final hasToggle =
widget.chatType == 'DIALOG' && real != null && real != widget.name;
widget.chatType == 'DIALOG' && real != null && real != custom;
final nameSwap = AnimatedTextSwap(
showAlternate: _showRealName,
alignment: Alignment.center,
alternate: Text(
real ?? widget.name,
real ?? custom,
style: nameStyle,
textAlign: TextAlign.center,
),
child: Text(widget.name, style: nameStyle, textAlign: TextAlign.center),
child: Text(custom, style: nameStyle, textAlign: TextAlign.center),
);
if (!hasToggle) return nameSwap;
@@ -583,6 +583,7 @@ class _ChatListScreenState extends State<ChatListScreen>
storiesModule.storiesChanged.addListener(_onStoriesDataChanged);
KometSettings.hideAllChatsFolder.addListener(_requestReload);
KometSettings.showHiddenChats.addListener(_requestReload);
ContactsModule.revision.addListener(_requestReload);
_maybeLoadStories();
_typingSub = api.pushStream
.where((p) => p.opcode == Opcode.notifTyping)
@@ -1228,6 +1229,7 @@ class _ChatListScreenState extends State<ChatListScreen>
storiesModule.storiesChanged.removeListener(_onStoriesDataChanged);
KometSettings.hideAllChatsFolder.removeListener(_requestReload);
KometSettings.showHiddenChats.removeListener(_requestReload);
ContactsModule.revision.removeListener(_requestReload);
_loginSub?.cancel();
_stateSub?.cancel();
_typingSub?.cancel();
+21 -4
View File
@@ -27,6 +27,7 @@ import '../../../main.dart';
import '../../../l10n/app_localizations.dart';
import '../../../backend/api.dart';
import '../../../backend/modules/messages.dart';
import '../../../backend/modules/contacts.dart';
import '../../../backend/modules/animoji.dart';
import '../../../models/animoji.dart';
import '../../../backend/modules/complaints.dart';
@@ -632,6 +633,7 @@ class _ChatScreenState extends State<ChatScreen>
});
debugForceOffline.addListener(_recomputeHeaderStatus);
PresenceFetch.revision.addListener(_onPresenceChanged);
ContactsModule.revision.addListener(_onContactsChanged);
_floatingDateAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
@@ -987,7 +989,7 @@ class _ChatScreenState extends State<ChatScreen>
MaterialPageRoute(
builder: (_) => ChatInfoScreen(
chatId: widget.chatId,
name: widget.name,
name: _headerName(),
imageUrl: widget.imageUrl,
chatType: widget.chatType,
initialTab: initialTab,
@@ -1823,6 +1825,7 @@ class _ChatScreenState extends State<ChatScreen>
.listenable(widget.chatId)
.removeListener(_recomputeHeaderStatus);
PresenceFetch.revision.removeListener(_onPresenceChanged);
ContactsModule.revision.removeListener(_onContactsChanged);
if (_wallpaperListening) {
ChatWallpaperStore.instance.revision.removeListener(
_applyEffectiveWallpaper,
@@ -2794,6 +2797,22 @@ class _ChatScreenState extends State<ChatScreen>
}
}
void _onContactsChanged() {
if (mounted) setState(() {});
}
String _headerName() {
if (_commentsMode) return AppLocalizations.of(context)!.commentsTitle;
if (widget.chatType == 'DIALOG') {
final otherId = _resolveOtherId();
if (otherId != null) {
final cached = ContactCache.get(otherId);
if (cached != null && cached.isNotEmpty) return cached;
}
}
return widget.name;
}
PreferredSizeWidget _buildAppBar(ColorScheme cs) {
final glossy = AppVisualStyle.current.value.glossyChrome;
final searchT = Curves.easeOut.transform(_searchAnim.value.clamp(0.0, 1.0));
@@ -2879,9 +2898,7 @@ class _ChatScreenState extends State<ChatScreen>
cs: cs,
embedded: widget.embedded,
chatId: widget.chatId,
name: _commentsMode
? AppLocalizations.of(context)!.commentsTitle
: widget.name,
name: _headerName(),
imageUrl: widget.imageUrl,
chatType: widget.chatType,
isOfficial: chat?.isOfficial ?? false,
@@ -0,0 +1,346 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:komet/backend/modules/contacts.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import 'package:komet/frontend/widgets/komet_avatar.dart';
import 'package:komet/l10n/app_localizations.dart';
import 'package:komet/main.dart';
enum EditContactAction { updated, removed }
class EditContactResult {
final EditContactAction action;
final String firstName;
final String lastName;
const EditContactResult(
this.action, {
this.firstName = '',
this.lastName = '',
});
}
Future<EditContactResult?> showEditContactSheet(
BuildContext context, {
required int contactId,
required String avatarUrl,
required String customFirst,
required String customLast,
required String onemeFirst,
required String onemeLast,
}) {
return showGeneralDialog<EditContactResult>(
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: (_, 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: _EditContactCard(
contactId: contactId,
avatarUrl: avatarUrl,
customFirst: customFirst,
customLast: customLast,
onemeFirst: onemeFirst,
onemeLast: onemeLast,
),
),
),
);
},
);
}
class _EditContactCard extends StatefulWidget {
final int contactId;
final String avatarUrl;
final String customFirst;
final String customLast;
final String onemeFirst;
final String onemeLast;
const _EditContactCard({
required this.contactId,
required this.avatarUrl,
required this.customFirst,
required this.customLast,
required this.onemeFirst,
required this.onemeLast,
});
@override
State<_EditContactCard> createState() => _EditContactCardState();
}
class _EditContactCardState extends State<_EditContactCard> {
late final TextEditingController _firstCtrl;
late final TextEditingController _lastCtrl;
bool _saving = false;
bool _deleting = false;
@override
void initState() {
super.initState();
_firstCtrl = TextEditingController(text: widget.customFirst);
_lastCtrl = TextEditingController(text: widget.customLast);
}
@override
void dispose() {
_firstCtrl.dispose();
_lastCtrl.dispose();
super.dispose();
}
bool get _busy => _saving || _deleting;
bool get _dirty =>
_firstCtrl.text.trim() != widget.customFirst.trim() ||
_lastCtrl.text.trim() != widget.customLast.trim();
Future<void> _save() async {
if (!_dirty || _busy) return;
setState(() => _saving = true);
final first = _firstCtrl.text.trim();
final last = _lastCtrl.text.trim();
final sendFirst = first.isEmpty ? widget.onemeFirst : first;
final sendLast = last.isEmpty ? widget.onemeLast : last;
final updated = await ContactsModule.updateContact(
api,
contactId: widget.contactId,
firstName: sendFirst,
lastName: sendLast,
);
if (!mounted) return;
if (updated == null) {
setState(() => _saving = false);
showCustomNotification(
context,
AppLocalizations.of(context)!.editContactError,
);
return;
}
Navigator.of(context).pop(
EditContactResult(
EditContactAction.updated,
firstName: updated.firstName,
lastName: updated.lastName ?? '',
),
);
}
Future<void> _delete() async {
if (_busy) return;
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.editContactDeleteConfirmTitle),
content: Text(l10n.editContactDeleteConfirmBody),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(l10n.editContactDeleteCancel),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(l10n.editContactDelete),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _deleting = true);
final ok = await ContactsModule.removeContact(api, widget.contactId);
if (!mounted) return;
if (!ok) {
setState(() => _deleting = false);
showCustomNotification(context, l10n.editContactError);
return;
}
Navigator.of(context).pop(
const EditContactResult(EditContactAction.removed),
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
final width = MediaQuery.sizeOf(context).width;
final avatarName = widget.customFirst.isNotEmpty
? widget.customFirst
: widget.onemeFirst;
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: Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: KometAvatar(
name: avatarName,
size: 88,
imageUrl: widget.avatarUrl.isEmpty
? null
: widget.avatarUrl,
),
),
const SizedBox(height: 20),
_inputRow(
cs,
controller: _firstCtrl,
hint: l10n.editContactFirstName,
),
_divider(cs),
_inputRow(
cs,
controller: _lastCtrl,
hint: l10n.editContactLastName,
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
child: _dirty
? Padding(
padding: const EdgeInsets.only(top: 12),
child: _saveButton(cs, l10n),
)
: const SizedBox(width: double.infinity),
),
const SizedBox(height: 4),
_deleteButton(cs, l10n),
],
),
),
),
),
),
);
}
Widget _inputRow(
ColorScheme cs, {
required TextEditingController controller,
required String hint,
}) {
final hasText = controller.text.isNotEmpty;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
maxLength: 60,
enabled: !_busy,
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),
),
),
),
if (hasText)
InkWell(
onTap: _busy
? null
: () => setState(() => controller.clear()),
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.close,
size: 18,
color: cs.onSurfaceVariant,
),
),
),
],
),
);
}
Widget _saveButton(ColorScheme cs, AppLocalizations l10n) {
return SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _busy ? null : _save,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(
l10n.editContactSave,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
);
}
Widget _deleteButton(ColorScheme cs, AppLocalizations l10n) {
return SizedBox(
width: double.infinity,
child: TextButton(
onPressed: _busy ? null : _delete,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
foregroundColor: cs.error,
),
child: _deleting
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: cs.error),
)
: Text(
l10n.editContactDelete,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
);
}
Widget _divider(ColorScheme cs) =>
Divider(height: 1, thickness: 0.5, color: cs.outlineVariant);
}
+11 -1
View File
@@ -934,5 +934,15 @@
},
"addContactNotFoundSubtitle": "This number isn't on the app yet",
"addContactSearchOther": "Search for other number",
"addContactError": "Couldn't add contact"
"addContactError": "Couldn't add contact",
"editContactMenu": "Edit contact",
"editContactTitle": "Edit contact",
"editContactFirstName": "First name",
"editContactLastName": "Last name",
"editContactSave": "Save",
"editContactDelete": "Delete contact",
"editContactDeleteConfirmTitle": "Delete contact?",
"editContactDeleteConfirmBody": "This contact will be removed from your list.",
"editContactDeleteCancel": "Cancel",
"editContactError": "Couldn't save changes"
}
+60
View File
@@ -4123,6 +4123,66 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Couldn\'t add contact'**
String get addContactError;
/// No description provided for @editContactMenu.
///
/// In en, this message translates to:
/// **'Edit contact'**
String get editContactMenu;
/// No description provided for @editContactTitle.
///
/// In en, this message translates to:
/// **'Edit contact'**
String get editContactTitle;
/// No description provided for @editContactFirstName.
///
/// In en, this message translates to:
/// **'First name'**
String get editContactFirstName;
/// No description provided for @editContactLastName.
///
/// In en, this message translates to:
/// **'Last name'**
String get editContactLastName;
/// No description provided for @editContactSave.
///
/// In en, this message translates to:
/// **'Save'**
String get editContactSave;
/// No description provided for @editContactDelete.
///
/// In en, this message translates to:
/// **'Delete contact'**
String get editContactDelete;
/// No description provided for @editContactDeleteConfirmTitle.
///
/// In en, this message translates to:
/// **'Delete contact?'**
String get editContactDeleteConfirmTitle;
/// No description provided for @editContactDeleteConfirmBody.
///
/// In en, this message translates to:
/// **'This contact will be removed from your list.'**
String get editContactDeleteConfirmBody;
/// No description provided for @editContactDeleteCancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get editContactDeleteCancel;
/// No description provided for @editContactError.
///
/// In en, this message translates to:
/// **'Couldn\'t save changes'**
String get editContactError;
}
class _AppLocalizationsDelegate
+31
View File
@@ -2148,4 +2148,35 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get addContactError => 'Couldn\'t add contact';
@override
String get editContactMenu => 'Edit contact';
@override
String get editContactTitle => 'Edit contact';
@override
String get editContactFirstName => 'First name';
@override
String get editContactLastName => 'Last name';
@override
String get editContactSave => 'Save';
@override
String get editContactDelete => 'Delete contact';
@override
String get editContactDeleteConfirmTitle => 'Delete contact?';
@override
String get editContactDeleteConfirmBody =>
'This contact will be removed from your list.';
@override
String get editContactDeleteCancel => 'Cancel';
@override
String get editContactError => 'Couldn\'t save changes';
}
+31
View File
@@ -2161,4 +2161,35 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get addContactError => 'Не удалось добавить контакт';
@override
String get editContactMenu => 'Редактировать контакт';
@override
String get editContactTitle => 'Редактировать контакт';
@override
String get editContactFirstName => 'Имя';
@override
String get editContactLastName => 'Фамилия';
@override
String get editContactSave => 'Сохранить';
@override
String get editContactDelete => 'Удалить контакт';
@override
String get editContactDeleteConfirmTitle => 'Удалить контакт?';
@override
String get editContactDeleteConfirmBody =>
'Контакт будет удалён из вашего списка.';
@override
String get editContactDeleteCancel => 'Отмена';
@override
String get editContactError => 'Не удалось сохранить изменения';
}
+11 -1
View File
@@ -709,5 +709,15 @@
},
"addContactNotFoundSubtitle": "Этого номера пока нет в приложении",
"addContactSearchOther": "Искать другой номер",
"addContactError": "Не удалось добавить контакт"
"addContactError": "Не удалось добавить контакт",
"editContactMenu": "Редактировать контакт",
"editContactTitle": "Редактировать контакт",
"editContactFirstName": "Имя",
"editContactLastName": "Фамилия",
"editContactSave": "Сохранить",
"editContactDelete": "Удалить контакт",
"editContactDeleteConfirmTitle": "Удалить контакт?",
"editContactDeleteConfirmBody": "Контакт будет удалён из вашего списка.",
"editContactDeleteCancel": "Отмена",
"editContactError": "Не удалось сохранить изменения"
}