From 66eb32bf44c0c2070d144a1f8fa29d722d14d304 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 15 Mar 2026 09:24:41 +0100 Subject: [PATCH] Fix BLE contact add failure --- lib/providers/connection_provider.dart | 16 ++ lib/screens/add_contact_screen.dart | 224 +++++++++++++++++++++++++ lib/screens/contacts_tab.dart | 57 ++++++- pubspec.lock | 8 +- pubspec.yaml | 2 + 5 files changed, 293 insertions(+), 14 deletions(-) create mode 100644 lib/screens/add_contact_screen.dart diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 6b330a6..4ff4bba 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -1358,6 +1358,22 @@ class ConnectionProvider with ChangeNotifier { } } + Future importContactAdvert(Uint8List contactAdvertFrame) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + _error = null; + await _activeService.importContact(contactAdvertFrame); + } catch (e) { + _error = 'Failed to import contact: $e'; + notifyListeners(); + } + } + /// Send text message to contact /// /// Returns true if the message was successfully sent to the BLE service. diff --git a/lib/screens/add_contact_screen.dart b/lib/screens/add_contact_screen.dart new file mode 100644 index 0000000..680cf58 --- /dev/null +++ b/lib/screens/add_contact_screen.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../providers/connection_provider.dart'; + +class AddContactScreen extends StatefulWidget { + const AddContactScreen({super.key}); + + @override + State createState() => _AddContactScreenState(); +} + +class _AddContactScreenState extends State { + final TextEditingController _advertController = TextEditingController(); + bool _isImporting = false; + String? _validationError; + + @override + void initState() { + super.initState(); + _loadClipboardIfPresent(); + } + + @override + void dispose() { + _advertController.dispose(); + super.dispose(); + } + + Future _loadClipboardIfPresent() async { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + final text = clipboardData?.text?.trim(); + if (!mounted || text == null || text.isEmpty) { + return; + } + if (_normalizeAdvertText(text) == null) { + return; + } + _advertController.text = text; + } + + String? _normalizeAdvertText(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + + var normalized = trimmed; + if (normalized.startsWith('meshcore://')) { + normalized = normalized.substring('meshcore://'.length); + } + + normalized = normalized.replaceAll(RegExp(r'\s+'), ''); + if (normalized.isEmpty) { + return null; + } + + final isHex = RegExp(r'^[0-9a-fA-F]+$').hasMatch(normalized); + if (!isHex || normalized.length.isOdd) { + return null; + } + + return normalized.toLowerCase(); + } + + Uint8List _hexToBytes(String hex) { + final bytes = []; + for (var i = 0; i < hex.length; i += 2) { + bytes.add(int.parse(hex.substring(i, i + 2), radix: 16)); + } + return Uint8List.fromList(bytes); + } + + Future _pasteFromClipboard() async { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + final text = clipboardData?.text; + if (text == null || text.trim().isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Clipboard is empty'))); + return; + } + + setState(() { + _advertController.text = text.trim(); + _validationError = null; + }); + } + + Future _importContact() async { + final normalized = _normalizeAdvertText(_advertController.text); + if (normalized == null) { + setState(() { + _validationError = + 'Enter a valid meshcore:// advert or raw hexadecimal contact advert.'; + }); + return; + } + + final advertBytes = _hexToBytes(normalized); + if (advertBytes.length < 98) { + setState(() { + _validationError = + 'Advert is too short. Expected exported contact data.'; + }); + return; + } + + setState(() { + _isImporting = true; + _validationError = null; + }); + + final connectionProvider = context.read(); + await connectionProvider.importContactAdvert(advertBytes); + final importError = connectionProvider.error; + + if (importError == null) { + await connectionProvider.getContacts(); + } + + if (!mounted) { + return; + } + + setState(() { + _isImporting = false; + }); + + if (importError != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(importError))); + return; + } + + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Contact imported'))); + Navigator.of(context).pop(true); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final normalized = _normalizeAdvertText(_advertController.text); + + return Scaffold( + appBar: AppBar(title: const Text('Add Contact')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Import an exported contact advert, like meshcore-open.', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Paste a `meshcore://...` link or raw hex advert from the clipboard.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _advertController, + minLines: 4, + maxLines: 8, + onChanged: (_) { + if (_validationError != null) { + setState(() { + _validationError = null; + }); + } + }, + decoration: InputDecoration( + labelText: 'Contact advert', + hintText: 'meshcore://...', + alignLabelWithHint: true, + border: const OutlineInputBorder(), + errorText: _validationError, + ), + ), + const SizedBox(height: 12), + if (normalized != null) + Text( + 'Advert size: ${normalized.length ~/ 2} bytes', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _isImporting ? null : _pasteFromClipboard, + icon: const Icon(Icons.content_paste_go_outlined), + label: const Text('Paste'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: _isImporting ? null : _importContact, + icon: _isImporting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.person_add_alt_1_outlined), + label: Text(_isImporting ? 'Importing...' : 'Add Contact'), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index ce10038..2d125c7 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -17,6 +17,7 @@ import '../utils/avatar_label_helper.dart'; import '../widgets/common/contact_avatar.dart'; import '../widgets/contacts/contact_tile.dart'; import '../widgets/contacts/add_channel_dialog.dart'; +import 'add_contact_screen.dart'; class ContactsTab extends StatefulWidget { final VoidCallback? onNavigateToMap; @@ -389,6 +390,12 @@ class _ContactsTabState extends State { ); } + Future _openAddContactScreen(BuildContext context) async { + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (context) => const AddContactScreen())); + } + Future _showDeleteChannelDialog( BuildContext context, Contact channel, @@ -659,6 +666,18 @@ class _ContactsTabState extends State { style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), + if (context + .watch() + .deviceInfo + .isConnected) + Padding( + padding: const EdgeInsets.only(top: 16), + child: OutlinedButton.icon( + onPressed: () => _openAddContactScreen(context), + icon: const Icon(Icons.person_add_alt_1_outlined), + label: const Text('Add Contact'), + ), + ), ], ), ); @@ -862,16 +881,36 @@ class _ContactsTabState extends State { horizontal: 16, vertical: 8, ), - child: OutlinedButton.icon( - onPressed: () => _showAddChannelDialog(context), - icon: const Icon(Icons.add_circle_outline), - label: Text(l10n.addChannel), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _openAddContactScreen(context), + icon: const Icon(Icons.person_add_alt_1_outlined), + label: const Text('Add Contact'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), ), - ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: () => _showAddChannelDialog(context), + icon: const Icon(Icons.add_circle_outline), + label: Text(l10n.addChannel), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), + ), + ], ), ), ], diff --git a/pubspec.lock b/pubspec.lock index 75f3edb..729e760 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -786,11 +786,9 @@ packages: meshcore_client: dependency: "direct main" description: - path: "." - ref: eafe9a88fb12193820e8abbf5a096e408117b53d - resolved-ref: eafe9a88fb12193820e8abbf5a096e408117b53d - url: "https://github.com/dz0ny/meshcore_client.git" - source: git + path: "../meshcore_client" + relative: true + source: path version: "0.1.0" meta: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index de6129b..e49a327 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -135,6 +135,8 @@ dev_dependencies: fake_async: ^1.3.3 dependency_overrides: + meshcore_client: + path: ../meshcore_client # path_provider_foundation 2.6.0 pulls in package:objective_c as a native # asset. That framework has been ending up archived with a macOS platform # slice and fails App Store validation for iOS uploads.