измененный вид контакта

This commit is contained in:
Jganenokk
2026-07-27 19:01:30 +07:00
parent 1a043f033d
commit 9e579f45cf
8 changed files with 502 additions and 71 deletions
@@ -1,9 +1,17 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../../core/config/app_colors.dart';
import '../../../../backend/modules/contacts.dart';
import '../../../../core/utils/haptics.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../main.dart' show api;
import '../../../../models/attachment.dart';
import '../../../screens/contacts/open_contact_profile.dart';
import '../../custom_notification.dart';
import '../../komet_avatar.dart';
import '../../small_spinner.dart';
import 'bubble_context.dart';
Widget buildContactCard(
@@ -13,86 +21,314 @@ Widget buildContactCard(
String? name,
String? photoUrl,
String? phoneNumber,
int? contactId,
String? userId,
}) {
final isMe = ctx.isMe;
return _ContactCard(
ctx: ctx,
firstName: firstName,
lastName: lastName,
name: name,
photoUrl: photoUrl,
phoneNumber: phoneNumber,
contactId: contactId,
userId: userId,
);
}
final first = firstName ?? '';
final last = lastName ?? '';
final hasFirstName = first.isNotEmpty;
final hasLastName = last.isNotEmpty;
class _ContactCard extends StatefulWidget {
final BubbleContext ctx;
final String? firstName;
final String? lastName;
final String? name;
final String? photoUrl;
final String? phoneNumber;
final int? contactId;
final String? userId;
final resolvedName = (hasFirstName || hasLastName)
? '${hasFirstName ? first : ''}${hasLastName ? ' $last' : ''}'.trim()
: (name ?? 'Contact');
const _ContactCard({
required this.ctx,
this.firstName,
this.lastName,
this.name,
this.photoUrl,
this.phoneNumber,
this.contactId,
this.userId,
});
final bgColor = isMe ? ctx.systemTint : ctx.cs.surfaceContainerHighest;
int? get resolvedContactId => contactId ?? int.tryParse(userId?.trim() ?? '');
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(24),
String get resolvedName {
final first = firstName?.trim() ?? '';
final last = lastName?.trim() ?? '';
final fullName = '$first $last'.trim();
if (fullName.isNotEmpty) return fullName;
final fallback = name?.trim() ?? '';
return fallback.isEmpty ? 'Contact' : fallback;
}
String get nameForAdd {
final first = firstName?.trim() ?? '';
return first.isEmpty ? resolvedName : first;
}
int get resolvedPhone {
final digits = phoneNumber?.replaceAll(RegExp(r'\D'), '') ?? '';
return int.tryParse(digits) ?? 0;
}
@override
State<_ContactCard> createState() => _ContactCardState();
}
class _ContactCardState extends State<_ContactCard> {
bool? _isContact;
bool _adding = false;
int _statusGeneration = 0;
@override
void initState() {
super.initState();
ContactsModule.revision.addListener(_onContactsChanged);
unawaited(_refreshContactStatus());
}
@override
void didUpdateWidget(_ContactCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.resolvedContactId != widget.resolvedContactId ||
oldWidget.ctx.message.accountId != widget.ctx.message.accountId ||
oldWidget.ctx.myId != widget.ctx.myId) {
_isContact = null;
unawaited(_refreshContactStatus());
}
}
@override
void dispose() {
ContactsModule.revision.removeListener(_onContactsChanged);
super.dispose();
}
void _onContactsChanged() {
unawaited(_refreshContactStatus());
}
Future<void> _refreshContactStatus() async {
final generation = ++_statusGeneration;
final contactId = widget.resolvedContactId;
if (contactId == null) {
if (mounted && generation == _statusGeneration) {
setState(() => _isContact = false);
}
return;
}
if (contactId == widget.ctx.myId) {
if (mounted && generation == _statusGeneration) {
setState(() => _isContact = true);
}
return;
}
CachedContact? contact;
try {
contact = await ContactsModule.getContact(
widget.ctx.message.accountId,
contactId,
);
} catch (_) {}
if (!mounted || generation != _statusGeneration) return;
setState(() => _isContact = contact != null);
}
Future<void> _addContact() async {
final contactId = widget.resolvedContactId;
if (contactId == null || _adding || _isContact != false) return;
Haptics.tap();
setState(() => _adding = true);
try {
final contact = await ContactsModule.addContact(
api,
contactId,
widget.nameForAdd,
phone: widget.resolvedPhone,
);
if (!mounted) return;
if (contact == null) {
showCustomNotification(
context,
AppLocalizations.of(context)!.addContactError,
);
return;
}
setState(() => _isContact = true);
showCustomNotification(
context,
AppLocalizations.of(context)!.nfcContactAdded,
);
} catch (_) {
if (mounted) {
showCustomNotification(
context,
AppLocalizations.of(context)!.addContactError,
);
}
} finally {
if (mounted) setState(() => _adding = false);
}
}
void _openProfile() {
final contactId = widget.resolvedContactId;
if (contactId == null) return;
Haptics.tap();
unawaited(
openContactDialogProfile(
context,
contactId: contactId,
name: widget.resolvedName,
avatarUrl: widget.photoUrl,
),
child: photoUrl != null && photoUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(24),
child: CachedNetworkImage(
imageUrl: photoUrl,
fit: BoxFit.cover,
memCacheWidth: kAvatarThumbSize,
memCacheHeight: kAvatarThumbSize,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) => Icon(
Symbols.person,
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
size: 24,
),
),
)
: Icon(
Symbols.person,
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
size: 24,
),
),
const SizedBox(width: 12),
Flexible(
);
}
@override
Widget build(BuildContext context) {
final ctx = widget.ctx;
final l10n = AppLocalizations.of(context)!;
final canAdd =
widget.resolvedContactId != null &&
widget.resolvedContactId != ctx.myId &&
_isContact != true;
final buttonColor = ctx.isMe
? Colors.black.withValues(alpha: 0.16)
: ctx.cs.onSurface.withValues(alpha: 0.08);
return SizedBox(
key: const ValueKey('contact-card'),
width: 320,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 8, 3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
KometAvatar(
name: widget.resolvedName,
imageUrl: widget.photoUrl,
size: 48,
fadeIn: false,
),
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
resolvedName.isNotEmpty ? resolvedName : 'Contact',
widget.resolvedName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: ctx.text,
fontSize: 15,
fontWeight: FontWeight.w500,
height: 1.2,
fontWeight: FontWeight.w600,
height: 1.15,
),
),
const SizedBox(height: 4),
Text(
_isContact == true
? l10n.contactBubbleAlreadyAdded
: l10n.contactBubbleNew,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (phoneNumber != null) ...[
const SizedBox(height: 2),
Text(
phoneNumber,
style: TextStyle(color: ctx.dim, fontSize: 12, height: 1.2),
),
],
],
style: TextStyle(
color: ctx.dim,
fontSize: 12,
height: 1.1,
),
),
],
),
),
if (canAdd) ...[
const SizedBox(width: 6),
_ContactActionButton(
key: const ValueKey('contact-add-button'),
tooltip: l10n.nfcAddContact,
icon: Symbols.person_add,
color: buttonColor,
foreground: ctx.text,
loading: _adding || _isContact == null,
onPressed: _isContact == false && !_adding
? _addContact
: null,
),
],
const SizedBox(width: 6),
_ContactActionButton(
key: const ValueKey('contact-profile-button'),
tooltip: l10n.contactBubbleOpenProfile,
icon: Symbols.chat_bubble,
color: buttonColor,
foreground: ctx.text,
onPressed: widget.resolvedContactId == null
? null
: _openProfile,
),
],
),
Align(alignment: Alignment.centerRight, child: ctx.meta()),
],
),
),
);
}
}
class _ContactActionButton extends StatelessWidget {
final String tooltip;
final IconData icon;
final Color color;
final Color foreground;
final bool loading;
final VoidCallback? onPressed;
const _ContactActionButton({
super.key,
required this.tooltip,
required this.icon,
required this.color,
required this.foreground,
this.loading = false,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Material(
color: color,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: SizedBox(
width: 38,
height: 38,
child: IconButton(
tooltip: tooltip,
padding: EdgeInsets.zero,
onPressed: onPressed,
icon: loading
? SmallSpinner(size: 17, color: foreground)
: Icon(icon, color: foreground, size: 20, fill: 1),
),
),
);
}
}
class ContactBubble extends StatelessWidget {
final BubbleContext ctx;
@@ -109,6 +345,8 @@ class ContactBubble extends StatelessWidget {
name: contact.name,
photoUrl: contact.photoUrl ?? contact.baseUrl,
phoneNumber: contact.phoneNumber,
contactId: contact.contactId,
userId: contact.userId,
);
}
}
@@ -261,6 +261,8 @@ class ForwardedContactBubble extends StatelessWidget {
name: contact.name,
photoUrl: contact.photoUrl ?? contact.baseUrl,
phoneNumber: contact.phoneNumber,
contactId: contact.contactId,
userId: contact.userId,
),
],
);
+3
View File
@@ -970,6 +970,9 @@
"addContactNotFoundSubtitle": "This number isn't on the app yet",
"addContactSearchOther": "Search for other number",
"addContactError": "Couldn't add contact",
"contactBubbleNew": "New contact",
"contactBubbleAlreadyAdded": "Already in your contacts",
"contactBubbleOpenProfile": "Open profile",
"editContactMenu": "Edit contact",
"editContactTitle": "Edit contact",
"editContactFirstName": "First name",
+18
View File
@@ -4232,6 +4232,24 @@ abstract class AppLocalizations {
/// **'Couldn\'t add contact'**
String get addContactError;
/// No description provided for @contactBubbleNew.
///
/// In en, this message translates to:
/// **'New contact'**
String get contactBubbleNew;
/// No description provided for @contactBubbleAlreadyAdded.
///
/// In en, this message translates to:
/// **'Already in your contacts'**
String get contactBubbleAlreadyAdded;
/// No description provided for @contactBubbleOpenProfile.
///
/// In en, this message translates to:
/// **'Open profile'**
String get contactBubbleOpenProfile;
/// No description provided for @editContactMenu.
///
/// In en, this message translates to:
+9
View File
@@ -2207,6 +2207,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get addContactError => 'Couldn\'t add contact';
@override
String get contactBubbleNew => 'New contact';
@override
String get contactBubbleAlreadyAdded => 'Already in your contacts';
@override
String get contactBubbleOpenProfile => 'Open profile';
@override
String get editContactMenu => 'Edit contact';
+9
View File
@@ -2221,6 +2221,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get addContactError => 'Не удалось добавить контакт';
@override
String get contactBubbleNew => 'Новый контакт';
@override
String get contactBubbleAlreadyAdded => 'Уже твой контакт';
@override
String get contactBubbleOpenProfile => 'Открыть профиль';
@override
String get editContactMenu => 'Редактировать контакт';
+3
View File
@@ -728,6 +728,9 @@
"addContactNotFoundSubtitle": "Этого номера пока нет в приложении",
"addContactSearchOther": "Искать другой номер",
"addContactError": "Не удалось добавить контакт",
"contactBubbleNew": "Новый контакт",
"contactBubbleAlreadyAdded": "Уже твой контакт",
"contactBubbleOpenProfile": "Открыть профиль",
"editContactMenu": "Редактировать контакт",
"editContactTitle": "Редактировать контакт",
"editContactFirstName": "Имя",
+149
View File
@@ -0,0 +1,149 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/backend/modules/contacts.dart';
import 'package:komet/backend/modules/messages.dart';
import 'package:komet/core/storage/app_database.dart';
import 'package:komet/frontend/widgets/message_bubble.dart';
import 'package:komet/l10n/app_localizations.dart';
import 'package:komet/models/attachment.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
class _SyntheticPathProvider extends PathProviderPlatform
with MockPlatformInterfaceMixin {
final String directory;
_SyntheticPathProvider(this.directory);
@override
Future<String?> getApplicationSupportPath() async => directory;
}
CachedMessage _contactMessage({
required String id,
required int contactId,
required String firstName,
required String lastName,
}) {
return CachedMessage(
id: id,
accountId: 1,
chatId: 2,
senderId: 3,
text: '',
time: DateTime(2026, 1, 2, 5, 46).millisecondsSinceEpoch,
attachments: [
ContactAttachment(
contactId: contactId,
firstName: firstName,
lastName: lastName,
name: '$firstName $lastName',
),
],
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('contact bubble shows profile and contextual contact action', (
tester,
) async {
final directory = Directory.systemTemp.createTempSync(
'synthetic_contact_bubble_test',
);
addTearDown(() async {
await AppDatabase.close();
if (directory.existsSync()) directory.deleteSync(recursive: true);
});
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
await tester.runAsync(() async {
await AppDatabase.init();
await AppDatabase.saveProfile(
ProfileData(
id: 1,
firstName: 'Synthetic owner',
phone: 100000,
country: 'ZZ',
accountStatus: 0,
updateTime: 1,
),
);
await AppDatabase.saveContacts([
{
'id': 77,
'account_id': 1,
'first_name': 'Existing',
'last_name': 'Contact',
'phone': 100001,
'photo_id': null,
'base_url': null,
'base_raw_url': null,
'update_time': 1,
'options': '',
},
]);
expect(await ContactsModule.getContact(1, 77), isNotNull);
});
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
MessageBubble(
message: _contactMessage(
id: 'synthetic-existing-message',
contactId: 77,
firstName: 'Existing',
lastName: 'Contact',
),
isMe: false,
myId: 1,
chatType: 'DIALOG',
),
MessageBubble(
message: _contactMessage(
id: 'synthetic-new-message',
contactId: 88,
firstName: 'New',
lastName: 'Contact',
),
isMe: false,
myId: 1,
chatType: 'DIALOG',
),
],
),
),
),
);
await tester.pump();
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 100)),
);
await tester.pump();
expect(find.text('Existing Contact'), findsOneWidget);
expect(find.text('Уже твой контакт'), findsOneWidget);
expect(find.text('New Contact'), findsOneWidget);
expect(find.text('Новый контакт'), findsOneWidget);
expect(find.byKey(const ValueKey('contact-add-button')), findsOneWidget);
expect(
find.byKey(const ValueKey('contact-profile-button')),
findsNWidgets(2),
);
expect(find.text('05:46'), findsNWidgets(2));
expect(
tester.getSize(find.byKey(const ValueKey('contact-card')).first).width,
320,
);
});
}