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
@@ -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);
}