feat(#610): render a received contact card as an Add Contact chip
The receive half of contact sharing. Until now an incoming <key:type:name> rendered as raw text while the stock app showed a native Add Contact button for the identical payload, so sharing worked outbound only: a stock user could add an Offband user from a card, but not the reverse. Confirmed by the owner on hardware. Reuses the proven mention-chip mechanism in TranslatedMessageContent rather than adding new machinery. Both patterns are now collected and sorted by position, so a message carrying a mention AND a card renders both in the right order; they cannot overlap, since a mention is @[...] and a card is <...>. Tapping opens the add dialog seeded with the card rather than adding silently. A contact is an identity, so adding one stays a deliberate act with the key, name and type visible first. Two deliberate refusals: - A card whose key is already a contact renders inert, mirroring stock, which warned the owner rather than silently re-adding. - A card matching the shape but failing the parser, such as an out-of-range type, falls back to plain text. Showing an Add chip there would promise something the parser will refuse. Parsing already existed from the #611 Gemini review, so this change is rendering and dedupe only. Epic #610 under Feature #609. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/610-contact-card-receive
parent
08416aa3cb
commit
2716afda50
@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import 'add_contact_by_key_dialog.dart';
|
||||
|
||||
/// Renders a received contact share card as a tappable Add Contact affordance
|
||||
/// instead of the raw `<key:type:name>` text. (#610)
|
||||
///
|
||||
/// This is the receive half of the exchange. The send half (#611) already
|
||||
/// emits this format, and the stock app already renders it as a native Add
|
||||
/// Contact button, so until now sharing worked outbound only: a stock user
|
||||
/// could add an Offband user from a card, but not the reverse.
|
||||
///
|
||||
/// Tapping opens the add dialog seeded with the card, so the user sees the key,
|
||||
/// name and type before anything is written to the radio. It does NOT add
|
||||
/// silently: a contact is an identity, and adding one should be a deliberate
|
||||
/// act with the details visible.
|
||||
class ContactCardChip extends StatelessWidget {
|
||||
const ContactCardChip({super.key, required this.card, required this.style});
|
||||
|
||||
/// The raw matched card text, `<key:type:name>`.
|
||||
final String card;
|
||||
|
||||
final TextStyle style;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final parsed = Contact.fromChannelShare(card);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final l10n = context.l10n;
|
||||
|
||||
// The regex matched the shape but the parser rejected the contents, e.g. a
|
||||
// type outside the documented range. Show the original text rather than a
|
||||
// chip that would lie about being addable.
|
||||
if (parsed == null) return Text(card, style: style);
|
||||
|
||||
// Mirrors what stock does: it warned the owner when the contact was
|
||||
// already held rather than silently re-adding.
|
||||
final known = context.select<MeshCoreConnector, bool>(
|
||||
(c) => c.contacts.any((x) => x.publicKeyHex == parsed.publicKeyHex),
|
||||
);
|
||||
|
||||
final label = known
|
||||
? l10n.contacts_cardAlreadyAdded(parsed.name)
|
||||
: l10n.contacts_cardAddContact(parsed.name);
|
||||
|
||||
final chip = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: known
|
||||
? scheme.onSurface.withValues(alpha: 0.08)
|
||||
: scheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
known ? Icons.how_to_reg : Icons.person_add_alt_1,
|
||||
size: 15,
|
||||
color: known ? scheme.onSurfaceVariant : scheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
style: style.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: known
|
||||
? scheme.onSurfaceVariant
|
||||
: scheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (known) {
|
||||
return Tooltip(
|
||||
message: l10n.contacts_cardAlreadyAddedTooltip,
|
||||
child: chip,
|
||||
);
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => showAddContactByKeyDialog(context, initialKeyText: card),
|
||||
child: chip,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
// Receiving a contact card (#610).
|
||||
//
|
||||
// Before this, an incoming <key:type:name> rendered as raw text while the stock
|
||||
// app showed a native Add Contact button for the very same payload. Sharing
|
||||
// worked outbound only.
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import 'package:meshcore_open/l10n/app_localizations.dart';
|
||||
import 'package:meshcore_open/models/contact.dart';
|
||||
import 'package:meshcore_open/widgets/translated_message_content.dart';
|
||||
|
||||
const _key = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff';
|
||||
const _card = '<$_key:1:Ka8sbi>';
|
||||
|
||||
class _Conn extends MeshCoreConnector {
|
||||
_Conn({this.known = const []});
|
||||
final List<Contact> known;
|
||||
|
||||
@override
|
||||
List<Contact> get contacts => known;
|
||||
}
|
||||
|
||||
Contact _contact(String keyHex) => Contact(
|
||||
publicKey: hex2Uint8List(keyHex),
|
||||
name: 'Ka8sbi',
|
||||
type: advTypeChat,
|
||||
pathLength: -1,
|
||||
path: Uint8List(0),
|
||||
lastSeen: DateTime(2026, 9, 9),
|
||||
);
|
||||
|
||||
Future<void> _pump(WidgetTester tester, String text, _Conn conn) =>
|
||||
tester.pumpWidget(
|
||||
ChangeNotifierProvider<MeshCoreConnector>.value(
|
||||
value: conn,
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: TranslatedMessageContent(
|
||||
displayText: text,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('a received card renders as an actionable chip, not raw text', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, 'here is mine $_card', _Conn());
|
||||
|
||||
// The raw payload must not be shown.
|
||||
expect(find.textContaining(_key), findsNothing);
|
||||
// An actionable affordance is offered instead.
|
||||
expect(find.byIcon(Icons.person_add_alt_1), findsOneWidget);
|
||||
expect(find.byType(InkWell), findsOneWidget);
|
||||
// The caption survives alongside it.
|
||||
expect(find.textContaining('here is mine'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a card for a known contact does not offer to re-add', (
|
||||
tester,
|
||||
) async {
|
||||
// Mirrors stock, which warned rather than silently re-adding.
|
||||
await _pump(tester, _card, _Conn(known: [_contact(_key)]));
|
||||
|
||||
expect(find.byIcon(Icons.how_to_reg), findsOneWidget);
|
||||
expect(find.byIcon(Icons.person_add_alt_1), findsNothing);
|
||||
// Not tappable, so it cannot be double-added.
|
||||
expect(find.byType(InkWell), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a malformed card falls back to plain text rather than lying', (
|
||||
tester,
|
||||
) async {
|
||||
// Right shape, impossible type. Rendering an Add chip here would promise
|
||||
// something the parser will refuse.
|
||||
const bad = '<$_key:9:Bob>';
|
||||
await _pump(tester, bad, _Conn());
|
||||
|
||||
expect(find.byIcon(Icons.person_add_alt_1), findsNothing);
|
||||
expect(find.textContaining(bad), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a mention and a card in one message both render', (
|
||||
tester,
|
||||
) async {
|
||||
// The two patterns are collected and sorted by position, so this is the
|
||||
// case that would break a naive single-regex implementation.
|
||||
await _pump(tester, '@[Bob] add this $_card', _Conn());
|
||||
|
||||
expect(find.textContaining('@Bob'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.person_add_alt_1), findsOneWidget);
|
||||
expect(find.textContaining(_key), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a plain message is untouched and keeps link support', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, 'just a normal message', _Conn());
|
||||
|
||||
expect(find.byIcon(Icons.person_add_alt_1), findsNothing);
|
||||
expect(find.textContaining('just a normal message'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('names with spaces and emoji survive into the chip label', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, '<$_key:2:Roger KY4RS 🧙>', _Conn());
|
||||
|
||||
expect(find.textContaining('Roger KY4RS 🧙'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.person_add_alt_1), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue