Remove showDirectMessageDialog usage

This commit is contained in:
Janez T
2026-03-07 14:24:10 +01:00
parent 3307a93640
commit 2c2e7b392a
6 changed files with 78 additions and 559 deletions

View File

@@ -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';

View File

@@ -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)

View File

@@ -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) ...[

View File

@@ -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<DirectMessageSheet> createState() => _DirectMessageSheetState();
}
class _DirectMessageSheetState extends State<DirectMessageSheet> {
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<void> _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<void> _sendDirectMessage() async {
final text = _textController.text.trim();
if (text.isEmpty) return;
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
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<AppProvider>();
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,
),
),
),
],
),
],
),
),
],
),
);
}
}

View File

@@ -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<MessageBubble> {
}
void _showMessageOptions(BuildContext context) {
// Determine if this is own message
final connectionProvider = context.read<ConnectionProvider>();
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<MessageBubble> {
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<MessageBubble> {
return raw.sublist(5, 5 + pathLen);
}
void _showReplySheet(BuildContext context) {
// Find the sender contact by public key prefix
final contactsProvider = context.read<ContactsProvider>();
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(

View File

@@ -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);
});
}