From 2c2e7b392aa687a0fd162623076bcf1beb005cab Mon Sep 17 00:00:00 2001 From: Janez T Date: Sat, 7 Mar 2026 14:24:10 +0100 Subject: [PATCH] Remove showDirectMessageDialog usage --- lib/screens/contacts_tab.dart | 1 + lib/services/map_marker_service.dart | 26 +- lib/widgets/contacts/contact_tile.dart | 39 +- .../contacts/direct_message_sheet.dart | 452 ------------------ lib/widgets/messages/message_bubble.dart | 56 --- test/services/map_marker_service_test.dart | 63 +++ 6 files changed, 78 insertions(+), 559 deletions(-) delete mode 100644 lib/widgets/contacts/direct_message_sheet.dart create mode 100644 test/services/map_marker_service_test.dart diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index f53ddf4..3dd82b7 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.dart'; import '../l10n/app_localizations.dart'; +import '../models/contact.dart'; import '../providers/contacts_provider.dart'; import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; diff --git a/lib/services/map_marker_service.dart b/lib/services/map_marker_service.dart index 88839b6..5ea3f0c 100644 --- a/lib/services/map_marker_service.dart +++ b/lib/services/map_marker_service.dart @@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import '../models/contact.dart'; import '../models/sar_marker.dart'; +import '../widgets/common/contact_avatar.dart'; import '../widgets/map/location_pointer.dart'; /// Centralized service for map marker management. @@ -77,9 +78,15 @@ class MapMarkerService { // Marker icon Container( decoration: BoxDecoration( - color: getContactMarkerColor(contact, context), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), + color: Colors.white, + shape: contact.type == ContactType.channel || + contact.type == ContactType.room + ? BoxShape.rectangle + : BoxShape.circle, + borderRadius: contact.type == ContactType.channel || + contact.type == ContactType.room + ? BorderRadius.circular(14) + : null, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), @@ -88,17 +95,8 @@ class MapMarkerService { ), ], ), - padding: const EdgeInsets.all(6), - child: contact.roleEmoji != null - ? Text( - contact.roleEmoji!, - style: const TextStyle(fontSize: 18), - ) - : Icon( - getContactMarkerIcon(contact), - color: Colors.white, - size: 18, - ), + padding: const EdgeInsets.all(2), + child: ContactAvatar(contact: contact, radius: 16), ), const SizedBox(height: 2), // Name label (without emoji) diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 25eefe2..884729c 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -10,7 +10,6 @@ import '../../providers/contacts_provider.dart'; import '../../providers/map_provider.dart'; import '../../providers/app_provider.dart'; import 'contact_route_dialog.dart'; -import 'direct_message_sheet.dart'; import 'room_login_sheet.dart'; import '../../utils/location_formats.dart'; import '../../utils/toast_logger.dart'; @@ -460,9 +459,9 @@ class ContactTile extends StatelessWidget { ) : null, onTap: () { - // In simple mode, tap directly opens message sheet for chat contacts + // In simple mode, tap directly opens the route editor for chat contacts if (isSimpleMode && contact.type == ContactType.chat) { - _showDirectMessageDialog(context, contact); + _showSetRouteDialog(context, contact); } else if (isSimpleMode && contact.type == ContactType.repeater) { // In simple mode, tapping a repeater jumps to the map _jumpToMapForRepeater(context, contact); @@ -515,15 +514,6 @@ class ContactTile extends StatelessWidget { ); } - void _showDirectMessageDialog(BuildContext context, Contact contact) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => DirectMessageSheet(contact: contact), - ); - } - void _showRoomLoginDialog(BuildContext context, Contact contact) { showModalBottomSheet( context: context, @@ -1038,31 +1028,6 @@ class ContactTile extends StatelessWidget { ], ), ], - // Direct Message button for chat contacts - if (contact.type == ContactType.chat) ...[ - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () { - Navigator.pop(context); // Close details first - _showDirectMessageDialog(context, contact); - }, - icon: const Icon(Icons.message), - label: Text( - AppLocalizations.of(context)!.sendDirectMessage, - ), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - backgroundColor: _getTypeColor( - contact.type, - context, - ), - foregroundColor: Colors.white, - ), - ), - ), - ], // Room Login button for room contacts (except Public Channel) if (contact.type == ContactType.room && !contact.isPublicChannel) ...[ diff --git a/lib/widgets/contacts/direct_message_sheet.dart b/lib/widgets/contacts/direct_message_sheet.dart deleted file mode 100644 index f0c12d6..0000000 --- a/lib/widgets/contacts/direct_message_sheet.dart +++ /dev/null @@ -1,452 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'package:geolocator/geolocator.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; -import '../../models/contact.dart'; -import '../../models/message.dart'; -import '../../providers/connection_provider.dart'; -import '../../providers/messages_provider.dart'; -import '../../providers/app_provider.dart'; -import '../../utils/toast_logger.dart'; -import '../../l10n/app_localizations.dart'; - -class DirectMessageSheet extends StatefulWidget { - final Contact contact; - - const DirectMessageSheet({super.key, required this.contact}); - - @override - State createState() => _DirectMessageSheetState(); -} - -class _DirectMessageSheetState extends State { - final TextEditingController _textController = TextEditingController(); - final FocusNode _focusNode = FocusNode(); - int _characterCount = 0; - static const int _maxCharacters = 160; - - @override - void initState() { - super.initState(); - _textController.addListener(_updateCharacterCount); - } - - @override - void dispose() { - _textController.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - void _updateCharacterCount() { - if (!mounted) return; - setState(() { - _characterCount = _textController.text.length; - }); - } - - /// Insert current GPS location at cursor position - Future _insertCurrentLocation() async { - try { - // Check location permission - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied) { - if (!mounted) return; - ToastLogger.error(context, 'Location permission denied'); - return; - } - } - - if (permission == LocationPermission.deniedForever) { - if (!mounted) return; - ToastLogger.error(context, 'Location permission permanently denied'); - return; - } - - // Get current position - final position = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings( - accuracy: LocationAccuracy.best, - ), - ); - - // Format location text - final locationText = - '📍 Lat: ${position.latitude.toStringAsFixed(5)}, Lon: ${position.longitude.toStringAsFixed(5)}'; - - // Check if adding location would exceed limit - final currentText = _textController.text; - if (currentText.length + locationText.length > _maxCharacters) { - if (!mounted) return; - ToastLogger.error( - context, - 'Adding location would exceed 160 character limit', - ); - return; - } - - // Insert at cursor position or append - final selection = _textController.selection; - final newText = currentText.replaceRange( - selection.start >= 0 ? selection.start : currentText.length, - selection.end >= 0 ? selection.end : currentText.length, - locationText, - ); - - _textController.text = newText; - - // Move cursor to end of inserted text - final newCursorPosition = - (selection.start >= 0 ? selection.start : currentText.length) + - locationText.length; - _textController.selection = TextSelection.fromPosition( - TextPosition(offset: newCursorPosition), - ); - - if (!mounted) return; - } catch (e) { - if (!mounted) return; - ToastLogger.error(context, 'Failed to get location: $e'); - } - } - - Future _sendDirectMessage() async { - final text = _textController.text.trim(); - if (text.isEmpty) return; - - final connectionProvider = context.read(); - final messagesProvider = context.read(); - - if (!connectionProvider.deviceInfo.isConnected) { - if (!mounted) return; - ToastLogger.error( - context, - AppLocalizations.of(context)!.notConnectedToDevice, - ); - return; - } - - try { - // Create message ID - final messageId = '${DateTime.now().millisecondsSinceEpoch}_dm_sent'; - final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - // Get current device's public key (first 6 bytes) - final devicePublicKey = connectionProvider.deviceInfo.publicKey; - final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); - - // Create sent message object with recipient public key for retry support - final sentMessage = Message( - id: messageId, - messageType: MessageType.contact, - senderPublicKeyPrefix: senderPublicKeyPrefix, - pathLen: 0, - textType: MessageTextType.plain, - senderTimestamp: timestamp, - text: text, - receivedAt: DateTime.now(), - deliveryStatus: MessageDeliveryStatus.sending, - recipientPublicKey: - widget.contact.publicKey, // Store recipient for retry - ); - - // Add to messages list with "sending" status - // Pass contact for retry logic - messagesProvider.addSentMessage(sentMessage, contact: widget.contact); - - // Send direct message to contact (include contact for path logging) - final sentSuccessfully = await connectionProvider.sendTextMessage( - contactPublicKey: widget.contact.publicKey, - text: text, - messageId: messageId, // Pass message ID for tracking - contact: widget.contact, - ); - - if (!sentSuccessfully) { - // Mark message as failed if sending failed - messagesProvider.markMessageFailed(messageId); - } - - _textController.clear(); - _focusNode.unfocus(); - - if (!mounted) return; - Navigator.pop(context); // Close the dialog - } catch (e) { - if (!mounted) return; - ToastLogger.error( - context, - AppLocalizations.of(context)!.failedToSend(e.toString()), - ); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final appProvider = context.watch(); - final isSimpleMode = appProvider.isSimpleMode; - final contactLocation = widget.contact.displayLocation; - - return Container( - height: MediaQuery.of(context).size.height * 0.9, - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(20), - ), - ), - child: Row( - children: [ - IconButton( - icon: Icon(Icons.arrow_back, color: colorScheme.onSurface), - onPressed: () => Navigator.pop(context), - ), - Expanded( - child: Column( - children: [ - Text( - AppLocalizations.of(context)!.directMessage, - style: TextStyle( - color: colorScheme.onSurface, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - Text( - widget.contact.displayName, - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: 14, - ), - ), - ], - ), - ), - const SizedBox(width: 48), // Spacer to keep title centered - ], - ), - ), - - // Mini map in simple mode (scrollable content) - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 16), - if (isSimpleMode && contactLocation != null) ...[ - GestureDetector( - onTap: () { - // Hide keyboard when tapping on map - _focusNode.unfocus(); - }, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - height: 200, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: colorScheme.outline), - ), - clipBehavior: Clip.antiAlias, - child: FlutterMap( - options: MapOptions( - initialCenter: LatLng( - contactLocation.latitude, - contactLocation.longitude, - ), - initialZoom: 13.0, - interactionOptions: const InteractionOptions( - flags: - InteractiveFlag.pinchZoom | - InteractiveFlag.drag, - ), - ), - children: [ - TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.meshcore.sar', - ), - MarkerLayer( - markers: [ - Marker( - point: LatLng( - contactLocation.latitude, - contactLocation.longitude, - ), - width: 40, - height: 40, - child: Icon( - Icons.location_on, - color: colorScheme.primary, - size: 40, - ), - ), - ], - ), - ], - ), - ), - ), - const SizedBox(height: 8), - // Location coordinates - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.gps_fixed, - size: 14, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - '${contactLocation.latitude.toStringAsFixed(5)}, ${contactLocation.longitude.toStringAsFixed(5)}', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: 12, - fontFamily: 'monospace', - ), - ), - ], - ), - ), - const SizedBox(height: 16), - ], - ], - ), - ), - ), - - // Message input - Container( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: 16 + MediaQuery.of(context).viewInsets.bottom, - ), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - ), - child: Column( - children: [ - TextField( - controller: _textController, - focusNode: _focusNode, - maxLength: _maxCharacters, - maxLines: 3, - autofocus: true, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - style: TextStyle(color: colorScheme.onSurface), - decoration: InputDecoration( - hintText: AppLocalizations.of(context)!.typeYourMessage, - hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colorScheme.outline), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colorScheme.outline), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: colorScheme.primary, - width: 2, - ), - ), - contentPadding: const EdgeInsets.all(16), - counterText: '', // Hide default counter - ), - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendDirectMessage(), - ), - // Always-visible character counter - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 4, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - '$_characterCount / $_maxCharacters', - style: TextStyle( - fontSize: 12, - color: _characterCount > 155 - ? Colors.red - : (_characterCount > 140 - ? Colors.orange - : colorScheme.onSurfaceVariant), - fontWeight: _characterCount > 140 - ? FontWeight.bold - : FontWeight.normal, - ), - ), - ], - ), - ), - const SizedBox(height: 8), - // Location and Send buttons - Row( - children: [ - OutlinedButton.icon( - onPressed: _insertCurrentLocation, - icon: const Icon(Icons.my_location, size: 18), - label: Text(AppLocalizations.of(context)!.myLocation), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - side: BorderSide(color: colorScheme.outline), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: _textController.text.trim().isEmpty - ? null - : _sendDirectMessage, - icon: const Icon(Icons.send), - label: Text( - AppLocalizations.of(context)!.sendDirectMessage, - ), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - disabledBackgroundColor: - colorScheme.surfaceContainerHighest, - disabledForegroundColor: colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index b89facb..c0210f1 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -13,7 +13,6 @@ import '../../providers/connection_provider.dart'; import '../../providers/drawing_provider.dart'; import '../../providers/voice_provider.dart'; import '../../providers/image_provider.dart' as ip; -import '../contacts/direct_message_sheet.dart'; import '../drawing_minimap_preview.dart'; import '../../models/ble_packet_log.dart'; import '../../services/sar_template_service.dart'; @@ -193,19 +192,12 @@ class _MessageBubbleState extends State { } void _showMessageOptions(BuildContext context) { - // Determine if this is own message final connectionProvider = context.read(); final selfPublicKey = connectionProvider.deviceInfo.publicKey; final isOwnMessage = widget.message.isSentMessage || widget.message.isFromSelf(selfPublicKey); - // Check if we can reply to this message (must be contact message from someone else) - final canReply = - widget.message.isContactMessage && - !isOwnMessage && - widget.message.senderPublicKeyPrefix != null; - showModalBottomSheet( context: context, backgroundColor: Theme.of(context).colorScheme.surface, @@ -217,16 +209,6 @@ class _MessageBubbleState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Reply option (only for contact messages from others) - if (canReply) - ListTile( - leading: const Icon(Icons.reply), - title: Text(AppLocalizations.of(context)!.reply), - onTap: () { - Navigator.pop(context); - _showReplySheet(context); - }, - ), // Copy text option ListTile( leading: const Icon(Icons.copy), @@ -1284,44 +1266,6 @@ class _MessageBubbleState extends State { return raw.sublist(5, 5 + pathLen); } - void _showReplySheet(BuildContext context) { - // Find the sender contact by public key prefix - final contactsProvider = context.read(); - - if (widget.message.senderPublicKeyPrefix == null) { - ToastLogger.error(context, 'Cannot reply: sender information missing'); - return; - } - - // Find contact by public key prefix (first 6 bytes) - final senderKeyHex = widget.message.senderPublicKeyPrefix! - .sublist( - 0, - widget.message.senderPublicKeyPrefix!.length < 6 - ? widget.message.senderPublicKeyPrefix!.length - : 6, - ) - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(''); - - final senderContact = contactsProvider.contacts.where((c) { - return c.publicKeyHex.startsWith(senderKeyHex); - }).firstOrNull; - - if (senderContact == null) { - ToastLogger.error(context, 'Cannot reply: contact not found'); - return; - } - - // Show direct message sheet for the sender - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => DirectMessageSheet(contact: senderContact), - ); - } - void _showDeleteConfirmation(BuildContext context) { final l10n = AppLocalizations.of(context)!; showDialog( diff --git a/test/services/map_marker_service_test.dart b/test/services/map_marker_service_test.dart new file mode 100644 index 0000000..344f02f --- /dev/null +++ b/test/services/map_marker_service_test.dart @@ -0,0 +1,63 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/services/map_marker_service.dart'; +import 'package:meshcore_sar_app/widgets/common/contact_avatar.dart'; + +void main() { + Contact buildContact({ + required String name, + required ContactType type, + required int advLat, + required int advLon, + }) { + return Contact( + publicKey: Uint8List(32), + type: type, + flags: 0, + outPathLen: 0, + outPath: Uint8List(0), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch, + advLat: advLat, + advLon: advLon, + lastMod: DateTime.now().millisecondsSinceEpoch, + ); + } + + testWidgets('contact map markers render shared contact avatars', (tester) async { + final service = MapMarkerService(); + final contact = buildContact( + name: 'John Smith', + type: ContactType.chat, + advLat: (46.0569 * 1e6).round(), + advLon: (14.5058 * 1e6).round(), + ); + + late Widget markerChild; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final markers = service.generateContactMarkers( + contacts: [contact], + context: context, + ); + markerChild = markers.single.child; + return const SizedBox.shrink(); + }, + ), + ), + ); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: Center(child: markerChild))), + ); + + expect(find.byType(ContactAvatar), findsOneWidget); + expect(find.text('JS'), findsOneWidget); + }); +}