diff --git a/lib/frontend/widgets/attachment/bubbles/contact_bubble.dart b/lib/frontend/widgets/attachment/bubbles/contact_bubble.dart index 5392da9..0c10496 100644 --- a/lib/frontend/widgets/attachment/bubbles/contact_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/contact_bubble.dart @@ -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,85 +21,313 @@ 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), - ), - 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, - ), + 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 _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 _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, + ), + ); + } + + @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( + 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( + widget.resolvedName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: ctx.text, + fontSize: 15, + fontWeight: FontWeight.w600, + height: 1.15, + ), + ), + const SizedBox(height: 4), + Text( + _isContact == true + ? l10n.contactBubbleAlreadyAdded + : l10n.contactBubbleNew, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: ctx.dim, + fontSize: 12, + height: 1.1, + ), + ), + ], ), - ) - : Icon( - Symbols.person, - color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - size: 24, ), - ), - const SizedBox(width: 12), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - resolvedName.isNotEmpty ? resolvedName : 'Contact', - style: TextStyle( - color: ctx.text, - fontSize: 15, - fontWeight: FontWeight.w500, - height: 1.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (phoneNumber != null) ...[ - const SizedBox(height: 2), - Text( - phoneNumber, - style: TextStyle(color: ctx.dim, fontSize: 12, height: 1.2), + 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 { @@ -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, ); } } diff --git a/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart b/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart index 428ec0c..335300c 100644 --- a/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart @@ -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, ), ], ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e53f1f1..bc8f730 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9c67def..69b64b3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b612924..84dbc40 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e7ef6e1..98f3fab 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'Редактировать контакт'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fb2df60..d9a4590 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -728,6 +728,9 @@ "addContactNotFoundSubtitle": "Этого номера пока нет в приложении", "addContactSearchOther": "Искать другой номер", "addContactError": "Не удалось добавить контакт", + "contactBubbleNew": "Новый контакт", + "contactBubbleAlreadyAdded": "Уже твой контакт", + "contactBubbleOpenProfile": "Открыть профиль", "editContactMenu": "Редактировать контакт", "editContactTitle": "Редактировать контакт", "editContactFirstName": "Имя", diff --git a/test/contact_bubble_test.dart b/test/contact_bubble_test.dart new file mode 100644 index 0000000..a21c329 --- /dev/null +++ b/test/contact_bubble_test.dart @@ -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 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.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, + ); + }); +}