diff --git a/ios/Runner.app.dSYM.zip b/ios/Runner.app.dSYM.zip index bfb8add..3458047 100644 Binary files a/ios/Runner.app.dSYM.zip and b/ios/Runner.app.dSYM.zip differ diff --git a/ios/Runner.ipa b/ios/Runner.ipa index 547b200..3b37fda 100644 Binary files a/ios/Runner.ipa and b/ios/Runner.ipa differ diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index def4b39..4146271 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/main.dart b/lib/main.dart index 8d694c3..455593a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -98,20 +98,22 @@ class _MeshCoreSarAppState extends State { Provider(create: (_) => TileCacheService()), // App provider that coordinates everything - ChangeNotifierProxyProvider4( + ChangeNotifierProxyProvider5( create: (context) => AppProvider( connectionProvider: context.read(), contactsProvider: context.read(), messagesProvider: context.read(), + drawingProvider: context.read(), tileCacheService: context.read(), ), - update: (context, conn, contacts, messages, tileCache, previous) => + update: (context, conn, contacts, messages, drawings, tileCache, previous) => previous ?? AppProvider( connectionProvider: conn, contactsProvider: contacts, messagesProvider: messages, + drawingProvider: drawings, tileCacheService: tileCache, ), ), diff --git a/lib/models/map_drawing.dart b/lib/models/map_drawing.dart index 7fff17e..fa89b2a 100644 --- a/lib/models/map_drawing.dart +++ b/lib/models/map_drawing.dart @@ -39,17 +39,28 @@ abstract class MapDrawing { final DrawingShapeType type; final Color color; final DateTime createdAt; + final String? senderName; // Name of sender (null if local drawing) + final bool isReceived; // True if drawing was received from another node MapDrawing({ required this.id, required this.type, required this.color, required this.createdAt, + this.senderName, + this.isReceived = false, }); /// Convert to JSON for persistence Map toJson(); + /// Convert to JSON for network transmission (includes sender name) + Map toNetworkJson(String senderName) { + final json = toJson(); + json['sender'] = senderName; + return json; + } + /// Create from JSON static MapDrawing? fromJson(Map json) { final typeStr = json['type'] as String?; @@ -81,6 +92,8 @@ class LineDrawing extends MapDrawing { required super.color, required super.createdAt, required this.points, + super.senderName, + super.isReceived, }) : super(type: DrawingShapeType.line); @override @@ -97,12 +110,15 @@ class LineDrawing extends MapDrawing { static LineDrawing fromJson(Map json) { final pointsJson = json['points'] as List; final points = pointsJson.map((p) => LatLng(p['lat'] as double, p['lon'] as double)).toList(); + final senderName = json['sender'] as String?; return LineDrawing( id: json['id'] as String, color: Color(json['color'] as int), createdAt: DateTime.parse(json['createdAt'] as String), points: points, + senderName: senderName, + isReceived: senderName != null, // Mark as received if sender is present ); } @@ -128,6 +144,8 @@ class RectangleDrawing extends MapDrawing { required super.createdAt, required this.topLeft, required this.bottomRight, + super.senderName, + super.isReceived, }) : super(type: DrawingShapeType.rectangle); /// Get all corner points for rendering @@ -154,6 +172,7 @@ class RectangleDrawing extends MapDrawing { static RectangleDrawing fromJson(Map json) { final topLeftJson = json['topLeft'] as Map; final bottomRightJson = json['bottomRight'] as Map; + final senderName = json['sender'] as String?; return RectangleDrawing( id: json['id'] as String, @@ -161,6 +180,8 @@ class RectangleDrawing extends MapDrawing { createdAt: DateTime.parse(json['createdAt'] as String), topLeft: LatLng(topLeftJson['lat'] as double, topLeftJson['lon'] as double), bottomRight: LatLng(bottomRightJson['lat'] as double, bottomRightJson['lon'] as double), + senderName: senderName, + isReceived: senderName != null, // Mark as received if sender is present ); } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index e25327b..1b0ef17 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -4,14 +4,17 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'connection_provider.dart'; import 'contacts_provider.dart'; import 'messages_provider.dart'; +import 'drawing_provider.dart'; import '../services/tile_cache_service.dart'; import '../models/contact.dart'; +import '../utils/drawing_message_parser.dart'; /// Main App Provider - coordinates all other providers class AppProvider with ChangeNotifier { final ConnectionProvider connectionProvider; final ContactsProvider contactsProvider; final MessagesProvider messagesProvider; + final DrawingProvider drawingProvider; final TileCacheService tileCacheService; bool _isInitialized = false; @@ -21,6 +24,7 @@ class AppProvider with ChangeNotifier { required this.connectionProvider, required this.contactsProvider, required this.messagesProvider, + required this.drawingProvider, required this.tileCacheService, }) { _setupCallbacks(); @@ -61,6 +65,20 @@ class AppProvider with ChangeNotifier { // When a message is received connectionProvider.onMessageReceived = (message) { + // Check if message is a drawing broadcast + if (DrawingMessageParser.isDrawingMessage(message.text)) { + debugPrint('🎨 [AppProvider] Drawing message received, parsing...'); + final drawing = DrawingMessageParser.parseDrawingMessage(message.text); + if (drawing != null) { + debugPrint('🎨 [AppProvider] Drawing parsed successfully: ${drawing.type.name} from ${drawing.senderName ?? "unknown"}'); + drawingProvider.addReceivedDrawing(drawing); + } else { + debugPrint('⚠️ [AppProvider] Failed to parse drawing message'); + } + // Don't add drawing messages to the message list + return; + } + // Pass contact lookup function to link channel messages with contacts messagesProvider.addMessage( message, diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart index b0ff331..18db4c1 100644 --- a/lib/providers/drawing_provider.dart +++ b/lib/providers/drawing_provider.dart @@ -1,8 +1,10 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/map_drawing.dart'; +import '../utils/drawing_message_parser.dart'; /// Drawing mode state enum DrawingMode { @@ -255,4 +257,23 @@ class DrawingProvider with ChangeNotifier { } return null; } + + /// Add received drawing from another node + void addReceivedDrawing(MapDrawing drawing) { + // Check if drawing with this ID already exists + if (_drawings.any((d) => d.id == drawing.id)) { + debugPrint('Drawing ${drawing.id} already exists, skipping'); + return; + } + + _drawings.add(drawing); + _saveDrawings(); + notifyListeners(); + } + + /// Broadcast a drawing to contacts + /// Returns the formatted message string ready to send + String createDrawingBroadcastMessage(MapDrawing drawing, String senderName) { + return DrawingMessageParser.createDrawingMessage(drawing, senderName); + } } diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index ee19a01..b7cdd65 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -1172,50 +1172,53 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { children: [ // Drawing toolbar const DrawingToolbar(), - const SizedBox(height: 8), - FloatingActionButton.small( - heroTag: 'center_map', - onPressed: !_isMapReady ? null : () async { - // Force update GPS location and jump to it - final position = await _locationService.getCurrentPosition(); - if (position != null && mounted) { - setState(() { - // Position updated in service - }); - _mapController.move( - LatLng(position.latitude, position.longitude), - 16, - ); - } else { - // Fallback to cached position or default center - final currentPosition = _locationService.currentPosition; - if (currentPosition != null) { + // Hide other buttons when in drawing mode + if (!drawingProvider.isDrawing) ...[ + const SizedBox(height: 8), + FloatingActionButton.small( + heroTag: 'center_map', + onPressed: !_isMapReady ? null : () async { + // Force update GPS location and jump to it + final position = await _locationService.getCurrentPosition(); + if (position != null && mounted) { + setState(() { + // Position updated in service + }); _mapController.move( - LatLng( - currentPosition.latitude, - currentPosition.longitude, - ), + LatLng(position.latitude, position.longitude), 16, ); } else { - _mapController.move(center, _defaultZoom); + // Fallback to cached position or default center + final currentPosition = _locationService.currentPosition; + if (currentPosition != null) { + _mapController.move( + LatLng( + currentPosition.latitude, + currentPosition.longitude, + ), + 16, + ); + } else { + _mapController.move(center, _defaultZoom); + } } - } - }, - child: const Icon(Icons.my_location), - ), - const SizedBox(height: 8), - FloatingActionButton.small( - heroTag: 'layer_selector', - onPressed: () => _showLayerSelector(context), - child: const Icon(Icons.layers), - ), - const SizedBox(height: 8), - FloatingActionButton.small( - heroTag: 'options_menu', - onPressed: () => _showOptionsMenu(context), - child: const Icon(Icons.more_vert), + }, + child: const Icon(Icons.my_location), ), + const SizedBox(height: 8), + FloatingActionButton.small( + heroTag: 'layer_selector', + onPressed: () => _showLayerSelector(context), + child: const Icon(Icons.layers), + ), + const SizedBox(height: 8), + FloatingActionButton.small( + heroTag: 'options_menu', + onPressed: () => _showOptionsMenu(context), + child: const Icon(Icons.more_vert), + ), + ], ], ), ), diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 902b1c1..1e4bc1f 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -10,6 +10,7 @@ import '../providers/connection_provider.dart'; import '../models/message.dart'; import '../models/sar_marker.dart'; import '../widgets/messages/sar_update_sheet.dart'; +import '../widgets/contacts/direct_message_sheet.dart'; import '../utils/toast_logger.dart'; class MessagesTab extends StatefulWidget { @@ -507,6 +508,16 @@ class _MessageBubble extends StatelessWidget { } void _showMessageOptions(BuildContext context) { + // Determine if this is own message + final connectionProvider = context.read(); + final selfPublicKey = connectionProvider.deviceInfo.publicKey; + final isOwnMessage = message.isSentMessage || message.isFromSelf(selfPublicKey); + + // Check if we can reply to this message (must be contact message from someone else) + final canReply = message.isContactMessage && + !isOwnMessage && + message.senderPublicKeyPrefix != null; + showModalBottomSheet( context: context, backgroundColor: Theme.of(context).colorScheme.surface, @@ -518,6 +529,16 @@ class _MessageBubble extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ + // Reply option (only for contact messages from others) + if (canReply) + ListTile( + leading: const Icon(Icons.reply), + title: const Text('Reply'), + onTap: () { + Navigator.pop(context); + _showReplySheet(context); + }, + ), // Copy text option ListTile( leading: const Icon(Icons.copy), @@ -543,6 +564,39 @@ class _MessageBubble extends StatelessWidget { ); } + void _showReplySheet(BuildContext context) { + // Find the sender contact by public key prefix + final contactsProvider = context.read(); + + if (message.senderPublicKeyPrefix == null) { + ToastLogger.error(context, 'Cannot reply: sender information missing'); + return; + } + + // Find contact by public key prefix (first 6 bytes) + final senderKeyHex = message.senderPublicKeyPrefix! + .sublist(0, message.senderPublicKeyPrefix!.length < 6 ? 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 + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => DirectMessageSheet(contact: senderContact), + ); + } + void _showDeleteConfirmation(BuildContext context) { showDialog( context: context, diff --git a/lib/utils/drawing_message_parser.dart b/lib/utils/drawing_message_parser.dart new file mode 100644 index 0000000..c2586ce --- /dev/null +++ b/lib/utils/drawing_message_parser.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; +import '../models/map_drawing.dart'; + +/// Parser for drawing messages transmitted over mesh network +class DrawingMessageParser { + /// Drawing message prefix + static const String prefix = 'D:'; + + /// Check if message is a drawing message + static bool isDrawingMessage(String text) { + return text.startsWith(prefix); + } + + /// Parse drawing message text into MapDrawing object + /// Returns null if parsing fails + static MapDrawing? parseDrawingMessage(String text) { + if (!isDrawingMessage(text)) { + return null; + } + + try { + // Remove prefix + final jsonStr = text.substring(prefix.length); + + // Parse JSON + final json = jsonDecode(jsonStr) as Map; + + // Use existing fromJson method + return MapDrawing.fromJson(json); + } catch (e) { + return null; + } + } + + /// Create drawing message text from MapDrawing object + /// Includes sender name in the message + static String createDrawingMessage(MapDrawing drawing, String senderName) { + final json = drawing.toNetworkJson(senderName); + final jsonStr = jsonEncode(json); + return '$prefix$jsonStr'; + } +} diff --git a/lib/widgets/map/drawing_layer.dart b/lib/widgets/map/drawing_layer.dart index 67b8e45..51db53d 100644 --- a/lib/widgets/map/drawing_layer.dart +++ b/lib/widgets/map/drawing_layer.dart @@ -34,14 +34,35 @@ class DrawingLayer extends StatelessWidget { /// Create a polyline from a drawing Polyline _createPolyline(MapDrawing drawing, {required bool isPreview}) { final points = _getPoints(drawing); - final opacity = isPreview ? 0.6 : 1.0; + + // Different styles for different drawing sources + final double opacity; + final double strokeWidth; + + if (isPreview) { + // Preview drawing (currently being drawn) + opacity = 0.6; + strokeWidth = 4.0; + } else if (drawing.isReceived) { + // Received drawing from another node (thinner, more transparent) + opacity = 0.7; + strokeWidth = 3.0; + } else { + // Local drawing (solid line, normal thickness) + opacity = 1.0; + strokeWidth = 4.0; + } return Polyline( points: points, color: drawing.color.withValues(alpha: opacity), - strokeWidth: 4.0, + strokeWidth: strokeWidth, borderColor: Colors.white.withValues(alpha: opacity * 0.8), borderStrokeWidth: 1.0, + // Use dotted pattern for received drawings + pattern: drawing.isReceived && !isPreview + ? StrokePattern.dotted(spacingFactor: 2) + : const StrokePattern.solid(), ); } @@ -71,33 +92,60 @@ class DrawingMarkersLayer extends StatelessWidget { @override Widget build(BuildContext context) { - // Only show delete buttons when showDeleteButtons is true - if (!showDeleteButtons) { - return const SizedBox.shrink(); - } - final List markers = []; - // Add delete markers for each drawing (at the center point) + // Add markers for each drawing for (final drawing in drawings) { final centerPoint = _getCenterPoint(drawing); if (centerPoint != null) { - markers.add( - Marker( - point: centerPoint, - width: 40, - height: 40, - child: GestureDetector( - onTap: () { - if (onDeleteDrawing != null) { - _showDeleteDialog(context, drawing); - } - }, + if (showDeleteButtons) { + // Show delete button when in drawing mode + markers.add( + Marker( + point: centerPoint, + width: 40, + height: 40, + child: GestureDetector( + onTap: () { + if (onDeleteDrawing != null) { + _showDeleteDialog(context, drawing); + } + }, + child: Container( + decoration: BoxDecoration( + color: drawing.color.withValues(alpha: 0.9), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: 20, + ), + ), + ), + ), + ); + } else if (drawing.isReceived && drawing.senderName != null) { + // Show sender badge for received drawings (when not in drawing mode) + markers.add( + Marker( + point: centerPoint, + width: 120, + height: 30, child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: drawing.color.withValues(alpha: 0.9), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white, width: 1.5), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), @@ -106,18 +154,40 @@ class DrawingMarkersLayer extends StatelessWidget { ), ], ), - child: const Icon( - Icons.close, - color: Colors.white, - size: 20, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.person, + color: Colors.white, + size: 14, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + drawing.senderName!, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], ), ), ), - ), - ); + ); + } } } + if (markers.isEmpty) { + return const SizedBox.shrink(); + } + return MarkerLayer(markers: markers); } diff --git a/lib/widgets/map/drawing_toolbar.dart b/lib/widgets/map/drawing_toolbar.dart index 4e514c2..88b35f5 100644 --- a/lib/widgets/map/drawing_toolbar.dart +++ b/lib/widgets/map/drawing_toolbar.dart @@ -1,7 +1,11 @@ +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/drawing_provider.dart'; +import '../../providers/connection_provider.dart'; +import '../../providers/contacts_provider.dart'; import '../../models/map_drawing.dart'; +import '../../models/contact.dart'; /// Toolbar for drawing controls on the map class DrawingToolbar extends StatelessWidget { @@ -50,8 +54,8 @@ class DrawingToolbar extends StatelessWidget { Text( _getTitleForMode(drawingProvider.drawingMode), style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + fontWeight: FontWeight.bold, + ), ), const SizedBox(width: 8), IconButton( @@ -62,30 +66,33 @@ class DrawingToolbar extends StatelessWidget { ), ], ), - const SizedBox(height: 12), - // Color picker + const SizedBox(height: 8), + // Color picker - more compact Wrap( - spacing: 8, + spacing: 4, + runSpacing: 4, children: DrawingColors.palette.map((color) { final isSelected = drawingProvider.selectedColor == color; return GestureDetector( onTap: () => drawingProvider.setColor(color), child: Container( - width: 32, - height: 32, + width: 24, + height: 24, decoration: BoxDecoration( color: color, shape: BoxShape.circle, border: Border.all( - color: isSelected ? Colors.white : Colors.grey.shade300, - width: isSelected ? 3 : 2, + color: isSelected + ? Colors.white + : Colors.grey.shade300, + width: isSelected ? 2 : 1, ), boxShadow: [ if (isSelected) BoxShadow( color: color.withValues(alpha: 0.5), - blurRadius: 8, - spreadRadius: 2, + blurRadius: 4, + spreadRadius: 1, ), ], ), @@ -93,14 +100,14 @@ class DrawingToolbar extends StatelessWidget { ? const Icon( Icons.check, color: Colors.white, - size: 16, + size: 12, ) : null, ), ); }).toList(), ), - const SizedBox(height: 12), + const SizedBox(height: 8), // Action buttons Row( mainAxisSize: MainAxisSize.min, @@ -112,6 +119,9 @@ class DrawingToolbar extends StatelessWidget { icon: const Icon(Icons.undo), onPressed: () => drawingProvider.cancelCurrentDrawing(), tooltip: 'Cancel', + iconSize: 20, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), ), // Complete line drawing if (drawingProvider.drawingMode == DrawingMode.line && @@ -121,31 +131,24 @@ class DrawingToolbar extends StatelessWidget { onPressed: () => drawingProvider.completeLine(), tooltip: 'Complete Line', color: Colors.green, + iconSize: 20, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), ), // Clear all drawings if (drawingProvider.drawings.isNotEmpty) IconButton( icon: const Icon(Icons.delete_sweep), - onPressed: () => _showClearAllDialog(context, drawingProvider), + onPressed: () => + _showClearAllDialog(context, drawingProvider), tooltip: 'Clear All', color: Colors.red, + iconSize: 20, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), ), ], ), - // Instructions - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _getInstructions(drawingProvider.drawingMode), - style: Theme.of(context).textTheme.bodySmall, - textAlign: TextAlign.center, - ), - ), ], ), ); @@ -171,8 +174,8 @@ class DrawingToolbar extends StatelessWidget { Text( 'Drawing Tools', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + fontWeight: FontWeight.bold, + ), ), ], ), @@ -198,10 +201,28 @@ class DrawingToolbar extends StatelessWidget { ), if (drawingProvider.drawings.isNotEmpty) ...[ const Divider(), + ListTile( + leading: const Icon(Icons.share, color: Colors.blue), + title: const Text('Share Drawings'), + subtitle: Text( + 'Broadcast ${drawingProvider.drawings.length} drawings to team', + ), + onTap: () async { + Navigator.pop(context); + // Small delay to ensure first bottom sheet is fully closed + // before opening the second one + await Future.delayed(const Duration(milliseconds: 100)); + if (context.mounted) { + _showShareDrawingsDialog(context, drawingProvider); + } + }, + ), ListTile( leading: const Icon(Icons.delete_sweep, color: Colors.red), title: const Text('Clear All Drawings'), - subtitle: Text('Remove all ${drawingProvider.drawings.length} drawings'), + subtitle: Text( + 'Remove all ${drawingProvider.drawings.length} drawings', + ), onTap: () { Navigator.pop(context); _showClearAllDialog(context, drawingProvider); @@ -215,7 +236,10 @@ class DrawingToolbar extends StatelessWidget { } /// Show clear all confirmation dialog - void _showClearAllDialog(BuildContext context, DrawingProvider drawingProvider) { + void _showClearAllDialog( + BuildContext context, + DrawingProvider drawingProvider, + ) { showDialog( context: context, builder: (context) => AlertDialog( @@ -233,9 +257,7 @@ class DrawingToolbar extends StatelessWidget { Navigator.pop(context); drawingProvider.clearAllDrawings(); }, - style: TextButton.styleFrom( - foregroundColor: Colors.red, - ), + style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('Clear All'), ), ], @@ -278,4 +300,262 @@ class DrawingToolbar extends StatelessWidget { return ''; } } + + /// Show share drawings dialog + void _showShareDrawingsDialog( + BuildContext context, + DrawingProvider drawingProvider, + ) { + debugPrint('🎨 [DrawingToolbar] _showShareDrawingsDialog called'); + + // Capture the root context BEFORE showing the modal + final rootContext = context; + + // Read providers BEFORE any async operations or dialogs + // This ensures we have the correct BuildContext + final connectionProvider = Provider.of(context, listen: false); + final contactsProvider = Provider.of(context, listen: false); + + debugPrint(' Connection status: ${connectionProvider.deviceInfo.isConnected}'); + + if (!connectionProvider.deviceInfo.isConnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Not connected to device'), + backgroundColor: Colors.red, + ), + ); + return; + } + + // Get device name for sender identification + final senderName = connectionProvider.deviceInfo.selfName ?? 'Unknown'; + + // Filter drawings (only share local drawings, not received ones) + final localDrawings = drawingProvider.drawings + .where((d) => !d.isReceived) + .toList(); + + debugPrint(' Local drawings count: ${localDrawings.length}'); + debugPrint(' Total drawings count: ${drawingProvider.drawings.length}'); + + if (localDrawings.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No local drawings to share'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + // Get available rooms + final rooms = contactsProvider.rooms; + debugPrint(' Available rooms: ${rooms.length}'); + + debugPrint(' Showing modal bottom sheet...'); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) => Container( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + const Icon(Icons.share), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Share ${localDrawings.length} Drawing${localDrawings.length > 1 ? 's' : ''}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + const Divider(), + // Option: Send to Public Channel + ListTile( + leading: const Icon(Icons.public, color: Colors.blue), + title: const Text('Public Channel'), + subtitle: const Text('Broadcast to all nearby nodes (ephemeral)'), + onTap: () async { + debugPrint('📤 [DrawingToolbar] Public Channel tapped'); + // Share BEFORE popping the navigator + await _shareDrawingsToChannel( + sheetContext, + localDrawings, + connectionProvider, + senderName, + ); + if (sheetContext.mounted) { + Navigator.pop(sheetContext); + } + }, + ), + // Option: Send to Room + if (rooms.isNotEmpty) ...[ + ...rooms.map( + (room) => ListTile( + leading: const Icon(Icons.meeting_room, color: Colors.green), + title: Text(room.advName), + subtitle: const Text('Stored permanently in room'), + onTap: () async { + debugPrint('📤 [DrawingToolbar] Room ${room.advName} tapped'); + // Share BEFORE popping the navigator + await _shareDrawingsToRoom( + sheetContext, + localDrawings, + connectionProvider, + senderName, + room, + ); + if (sheetContext.mounted) { + Navigator.pop(sheetContext); + } + }, + ), + ), + ], + ], + ), + ), + ); + } + + /// Share drawings to public channel + Future _shareDrawingsToChannel( + BuildContext context, + List drawings, + ConnectionProvider connectionProvider, + String senderName, + ) async { + debugPrint('📤 [DrawingToolbar] _shareDrawingsToChannel called'); + debugPrint(' Drawings to share: ${drawings.length}'); + debugPrint(' Sender name: $senderName'); + debugPrint(' Context mounted: ${context.mounted}'); + + if (!context.mounted) { + debugPrint('❌ Context not mounted, aborting'); + return; + } + + final drawingProvider = Provider.of(context, listen: false); + int successCount = 0; + + for (final drawing in drawings) { + try { + debugPrint(' Creating message for drawing ${drawing.id}...'); + final message = drawingProvider.createDrawingBroadcastMessage( + drawing, + senderName, + ); + debugPrint(' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...'); + debugPrint(' Sending to channel 0...'); + await connectionProvider.sendChannelMessage( + channelIdx: 0, + text: message, + ); + debugPrint(' ✅ Sent successfully'); + successCount++; + // Small delay between messages to avoid overwhelming the device + await Future.delayed(const Duration(milliseconds: 200)); + } catch (e, stackTrace) { + debugPrint('❌ Failed to share drawing ${drawing.id}: $e'); + debugPrint(' Stack trace: $stackTrace'); + } + } + + debugPrint(' Share complete: $successCount/${drawings.length} sent'); + debugPrint(' Context mounted after send: ${context.mounted}'); + + if (!context.mounted) { + debugPrint('❌ Context not mounted, cannot show snackbar'); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Shared $successCount/${drawings.length} drawings to Public Channel', + ), + backgroundColor: successCount == drawings.length + ? Colors.green + : Colors.orange, + ), + ); + } + + /// Share drawings to a specific room + Future _shareDrawingsToRoom( + BuildContext context, + List drawings, + ConnectionProvider connectionProvider, + String senderName, + Contact room, + ) async { + debugPrint('📤 [DrawingToolbar] _shareDrawingsToRoom called'); + debugPrint(' Room: ${room.advName}'); + debugPrint(' Drawings to share: ${drawings.length}'); + debugPrint(' Sender name: $senderName'); + debugPrint(' Context mounted: ${context.mounted}'); + + if (!context.mounted) { + debugPrint('❌ Context not mounted, aborting'); + return; + } + + final drawingProvider = Provider.of(context, listen: false); + int successCount = 0; + + for (final drawing in drawings) { + try { + debugPrint(' Creating message for drawing ${drawing.id}...'); + final message = drawingProvider.createDrawingBroadcastMessage( + drawing, + senderName, + ); + debugPrint(' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...'); + debugPrint(' Sending to room ${room.advName}...'); + await connectionProvider.sendTextMessage( + contactPublicKey: room.publicKey, + text: message, + ); + debugPrint(' ✅ Sent successfully'); + successCount++; + // Small delay between messages to avoid overwhelming the device + await Future.delayed(const Duration(milliseconds: 200)); + } catch (e, stackTrace) { + debugPrint( + '❌ Failed to share drawing ${drawing.id} to ${room.advName}: $e', + ); + debugPrint(' Stack trace: $stackTrace'); + } + } + + debugPrint(' Share complete: $successCount/${drawings.length} sent'); + debugPrint(' Context mounted after send: ${context.mounted}'); + + if (!context.mounted) { + debugPrint('❌ Context not mounted, cannot show snackbar'); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Shared $successCount/${drawings.length} drawings to ${room.advName}', + ), + backgroundColor: successCount == drawings.length + ? Colors.green + : Colors.orange, + ), + ); + } }