feat: Enhance Device Configuration Screen with Device Info and Telemetry Modes

- Added a new section to display device information including Device Type, Max Contacts, Max Channels, Telemetry Modes, and Manual Add Contacts.
- Implemented helper methods to convert device types and telemetry modes to user-friendly strings.
- Removed Message History Screen and its references from the Home Screen.
- Simplified message sending logic in Messages Tab to always send to the public channel.
- Introduced a new channel selection feature in the SAR Update Sheet for sending messages.
- Updated MeshCore BLE service to handle send confirmation responses and improved message sending protocols.
This commit is contained in:
Janez T
2025-10-14 20:43:58 +02:00
parent 5ff4138de0
commit 219eeecacd
8 changed files with 1121 additions and 1026 deletions

View File

@@ -1,12 +1,24 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/contacts_provider.dart';
import '../providers/connection_provider.dart';
import '../providers/app_provider.dart';
import '../models/contact.dart';
class ContactsTab extends StatelessWidget {
class ContactsTab extends StatefulWidget {
const ContactsTab({super.key});
@override
State<ContactsTab> createState() => _ContactsTabState();
}
class _ContactsTabState extends State<ContactsTab> {
Future<void> _handleRefresh() async {
final appProvider = context.read<AppProvider>();
await appProvider.refresh();
}
@override
Widget build(BuildContext context) {
return Consumer<ContactsProvider>(
@@ -32,7 +44,7 @@ class ContactsTab extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'Connect to a device and refresh to load contacts',
'Connect to a device to load contacts',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
@@ -41,9 +53,11 @@ class ContactsTab extends StatelessWidget {
);
}
return ListView(
padding: const EdgeInsets.all(8),
children: [
return RefreshIndicator(
onRefresh: _handleRefresh,
child: ListView(
padding: const EdgeInsets.all(8),
children: [
// Team Members (Chat contacts)
if (chatContacts.isNotEmpty) ...[
_SectionHeader(
@@ -75,7 +89,8 @@ class ContactsTab extends StatelessWidget {
),
...rooms.map((contact) => _ContactTile(contact: contact)),
],
],
],
),
);
},
);
@@ -234,19 +249,32 @@ class _ContactTile extends StatelessWidget {
),
],
),
trailing: IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: () {
final connectionProvider = context.read<ConnectionProvider>();
connectionProvider.requestTelemetry(contact.publicKey);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Requesting telemetry from ${contact.displayName}'),
duration: const Duration(seconds: 2),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Message icon - only for chat contacts
if (contact.type == ContactType.chat)
IconButton(
icon: const Icon(Icons.message, size: 20),
onPressed: () => _showDirectMessageDialog(context, contact),
tooltip: 'Send direct message',
),
);
},
tooltip: 'Request telemetry',
// Telemetry refresh button
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: () {
final connectionProvider = context.read<ConnectionProvider>();
connectionProvider.requestTelemetry(contact.publicKey);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Requesting telemetry from ${contact.displayName}'),
duration: const Duration(seconds: 2),
),
);
},
tooltip: 'Request telemetry',
),
],
),
onTap: () => _showContactDetails(context, contact),
onLongPress: () {
@@ -263,6 +291,15 @@ class _ContactTile extends StatelessWidget {
);
}
void _showDirectMessageDialog(BuildContext context, Contact contact) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => _DirectMessageSheet(contact: contact),
);
}
void _showContactDetails(BuildContext context, Contact contact) {
showModalBottomSheet(
context: context,
@@ -478,3 +515,236 @@ class _ContactTile extends StatelessWidget {
}
}
}
// Direct Message Sheet Widget
class _DirectMessageSheet extends StatefulWidget {
final Contact contact;
const _DirectMessageSheet({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() {
setState(() {
_characterCount = _textController.text.length;
});
}
Future<void> _sendDirectMessage() async {
final text = _textController.text.trim();
if (text.isEmpty) return;
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Not connected to device'),
backgroundColor: Colors.red,
),
);
return;
}
try {
// Send direct message to contact
await connectionProvider.sendTextMessage(
contactPublicKey: widget.contact.publicKey,
text: text,
);
_textController.clear();
_focusNode.unfocus();
if (!mounted) return;
Navigator.pop(context); // Close the dialog
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Direct message sent to ${widget.contact.displayName}'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to send: $e'),
backgroundColor: Colors.red,
),
);
}
}
@override
Widget build(BuildContext context) {
return Container(
height: MediaQuery.of(context).size.height * 0.9,
decoration: const BoxDecoration(
color: Color(0xFF1E1E1E),
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
Expanded(
child: Column(
children: [
const Text(
'Direct Message',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
widget.contact.displayName,
style: const TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.more_vert, color: Colors.white),
onPressed: () {},
),
],
),
),
// Info banner
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer),
const SizedBox(width: 12),
Expanded(
child: Text(
'This message will be sent directly to ${widget.contact.displayName}. It will also appear in the main messages feed.',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontSize: 13,
),
),
),
],
),
),
const SizedBox(height: 16),
const Spacer(),
// Message input
Container(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
),
decoration: const BoxDecoration(
color: Color(0xFF2D2D2D),
),
child: Column(
children: [
TextField(
controller: _textController,
focusNode: _focusNode,
maxLength: _maxCharacters,
maxLines: 3,
autofocus: true,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Type your message...',
hintStyle: const TextStyle(color: Colors.grey),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.grey),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.grey),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.white),
),
contentPadding: const EdgeInsets.all(16),
counterText: _characterCount >= 150
? '$_characterCount/$_maxCharacters'
: '',
counterStyle: TextStyle(
fontSize: 11,
color: _characterCount > _maxCharacters * 0.9
? Colors.orange
: Colors.grey,
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendDirectMessage(),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _textController.text.trim().isEmpty
? null
: _sendDirectMessage,
icon: const Icon(Icons.send),
label: const Text('Send Direct Message'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
),
),
],
),
);
}
}

View File

@@ -265,6 +265,48 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}
}
String _getDeviceTypeString(int? deviceType) {
if (deviceType == null) return 'Unknown';
switch (deviceType) {
case 0:
return 'None/Unknown';
case 1:
return 'Chat Node';
case 2:
return 'Repeater';
case 3:
return 'Room/Channel Server';
default:
return 'Type $deviceType';
}
}
String _getTelemetryModesString(deviceInfo) {
if (deviceInfo.telemetryModes == null) return 'Unknown';
final telemetryModes = deviceInfo.telemetryModes!;
final baseMode = telemetryModes & 0x03; // bits 0-1
final locationMode = (telemetryModes >> 2) & 0x03; // bits 2-3
String baseModeStr = _getTelemetryModeString(baseMode);
String locationModeStr = _getTelemetryModeString(locationMode);
return 'Base: $baseModeStr, Loc: $locationModeStr';
}
String _getTelemetryModeString(int mode) {
switch (mode) {
case 0:
return 'Deny';
case 1:
return 'By Contact';
case 2:
return 'Allow All';
default:
return 'Unknown';
}
}
Future<void> _useCurrentLocation() async {
try {
// Check if location services are enabled
@@ -474,6 +516,101 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
const SizedBox(height: 24),
// Device Information Section (Read-only)
_SectionHeader(
title: 'Device Information',
trailing: IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Device Information'),
content: const Text(
'This information is provided by the MeshCore device '
'and cannot be edited. Tap refresh to update.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('GOT IT'),
),
],
),
);
},
iconSize: 20,
),
),
_SettingTile(
icon: Icons.numbers,
label: 'Device Type',
isFirst: true,
trailing: Text(
_getDeviceTypeString(deviceInfo.deviceType),
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
),
_SettingTile(
icon: Icons.groups,
label: 'Max Contacts',
trailing: Text(
deviceInfo.maxContacts != null
? deviceInfo.maxContacts.toString()
: 'Unknown',
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
),
_SettingTile(
icon: Icons.tag,
label: 'Max Channels',
trailing: Text(
deviceInfo.maxChannels != null
? deviceInfo.maxChannels.toString()
: 'Unknown',
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
),
_SettingTile(
icon: Icons.settings_suggest,
label: 'Telemetry Modes',
trailing: Text(
_getTelemetryModesString(deviceInfo),
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
),
_SettingTile(
icon: Icons.group_add,
label: 'Manual Add Contacts',
isLast: true,
trailing: Text(
deviceInfo.manualAddContacts == true ? 'Enabled' : 'Disabled',
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
),
const SizedBox(height: 24),
// Public Info Section
_SectionHeader(
title: 'Public Info',

View File

@@ -10,7 +10,6 @@ import 'map_management_screen.dart';
import 'settings_screen.dart';
import 'device_config_screen.dart';
import 'packet_log_screen.dart';
import 'message_history_screen.dart';
class HomeScreen extends StatefulWidget {
final Function(AppThemeMode) onThemeChanged;
@@ -233,43 +232,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
PopupMenuButton(
icon: const Icon(Icons.more_vert),
itemBuilder: (context) => [
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.history),
SizedBox(width: 8),
Text('Message History'),
],
),
onTap: () {
Future.delayed(Duration.zero, () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const MessageHistoryScreen(),
),
);
});
},
),
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.refresh),
SizedBox(width: 8),
Text('Refresh Contacts'),
],
),
onTap: () async {
final appProvider = context.read<AppProvider>();
await appProvider.refresh();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Refreshed contacts')),
);
}
},
),
PopupMenuItem(
child: const Row(
children: [

View File

@@ -1,490 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/messages_provider.dart';
import '../models/message.dart';
/// Screen to view all stored message history
class MessageHistoryScreen extends StatefulWidget {
const MessageHistoryScreen({super.key});
@override
State<MessageHistoryScreen> createState() => _MessageHistoryScreenState();
}
class _MessageHistoryScreenState extends State<MessageHistoryScreen> {
String _searchQuery = '';
MessageFilter _filter = MessageFilter.all;
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _showStorageInfo(BuildContext context) async {
final messagesProvider = context.read<MessagesProvider>();
final stats = await messagesProvider.getStorageStats();
if (!mounted) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Storage Information'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoRow(
label: 'Total Messages',
value: '${stats['messageCount']}',
),
const SizedBox(height: 8),
_InfoRow(
label: 'Storage Size',
value: '${stats['storageSizeKB']} KB',
),
const SizedBox(height: 8),
_InfoRow(
label: 'Storage Size (bytes)',
value: '${stats['storageSizeBytes']} bytes',
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
void _showClearConfirmation(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear All Messages?'),
content: const Text(
'This will permanently delete all stored messages. This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
context.read<MessagesProvider>().clearAll();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('All messages cleared'),
backgroundColor: Colors.green,
),
);
},
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
child: const Text('Clear All'),
),
],
),
);
}
List<Message> _filterMessages(List<Message> messages) {
// Apply search filter
var filtered = messages.where((msg) {
if (_searchQuery.isEmpty) return true;
final query = _searchQuery.toLowerCase();
return msg.text.toLowerCase().contains(query) ||
msg.displaySender.toLowerCase().contains(query);
}).toList();
// Apply type filter
switch (_filter) {
case MessageFilter.all:
break;
case MessageFilter.contact:
filtered = filtered.where((m) => m.isContactMessage).toList();
break;
case MessageFilter.channel:
filtered = filtered.where((m) => m.isChannelMessage).toList();
break;
case MessageFilter.sarMarker:
filtered = filtered.where((m) => m.isSarMarker).toList();
break;
}
// Sort by most recent first
filtered.sort((a, b) => b.sentAt.compareTo(a.sentAt));
return filtered;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Message History'),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
tooltip: 'Storage Info',
onPressed: () => _showStorageInfo(context),
),
IconButton(
icon: const Icon(Icons.delete_forever),
tooltip: 'Clear All',
onPressed: () => _showClearConfirmation(context),
),
],
),
body: Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) {
final messages = _filterMessages(messagesProvider.messages);
return Column(
children: [
// Search and filter bar
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Column(
children: [
// Search field
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search messages...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
_searchController.clear();
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
isDense: true,
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
const SizedBox(height: 8),
// Filter chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_FilterChip(
label: 'All (${messagesProvider.messages.length})',
isSelected: _filter == MessageFilter.all,
onTap: () => setState(() => _filter = MessageFilter.all),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Contacts (${messagesProvider.contactMessages.length})',
isSelected: _filter == MessageFilter.contact,
onTap: () => setState(() => _filter = MessageFilter.contact),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Channels (${messagesProvider.channelMessages.length})',
isSelected: _filter == MessageFilter.channel,
onTap: () => setState(() => _filter = MessageFilter.channel),
),
const SizedBox(width: 8),
_FilterChip(
label: 'SAR (${messagesProvider.sarMarkerMessages.length})',
isSelected: _filter == MessageFilter.sarMarker,
onTap: () => setState(() => _filter = MessageFilter.sarMarker),
),
],
),
),
],
),
),
// Message list
Expanded(
child: messages.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_searchQuery.isNotEmpty
? Icons.search_off
: Icons.inbox_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
_searchQuery.isNotEmpty
? 'No messages found'
: 'No messages stored',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
_searchQuery.isNotEmpty
? 'Try a different search term'
: 'Messages will appear here once received',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
return _MessageHistoryCard(message: message);
},
),
),
],
);
},
),
);
}
}
enum MessageFilter {
all,
contact,
channel,
sarMarker,
}
class _FilterChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _FilterChip({
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (_) => onTap(),
selectedColor: Theme.of(context).colorScheme.primaryContainer,
checkmarkColor: Theme.of(context).colorScheme.onPrimaryContainer,
);
}
}
class _MessageHistoryCard extends StatelessWidget {
final Message message;
const _MessageHistoryCard({required this.message});
String _formatDateTime(DateTime dateTime) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final messageDate = DateTime(dateTime.year, dateTime.month, dateTime.day);
final hour = dateTime.hour.toString().padLeft(2, '0');
final minute = dateTime.minute.toString().padLeft(2, '0');
final timeStr = '$hour:$minute';
if (messageDate == today) {
return 'Today $timeStr';
} else if (messageDate == today.subtract(const Duration(days: 1))) {
return 'Yesterday $timeStr';
} else if (now.difference(dateTime).inDays < 7) {
final weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
final weekday = weekdays[dateTime.weekday - 1];
return '$weekday $timeStr';
} else {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
final month = months[dateTime.month - 1];
return '$month ${dateTime.day}, ${dateTime.year} $timeStr';
}
}
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row
Row(
children: [
// Type icon
Icon(
message.isChannelMessage ? Icons.tag : Icons.person,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 4),
// Sender
Expanded(
child: Text(
message.displaySender,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
// SAR badge
if (message.isSarMarker) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'SAR',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
const SizedBox(height: 8),
// Message content
if (message.isSarMarker && message.sarMarkerType != null) ...[
Row(
children: [
Text(
message.sarMarkerType!.emoji,
style: const TextStyle(fontSize: 24),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message.sarMarkerType!.displayName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (message.sarGpsCoordinates != null)
Text(
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
],
),
),
],
),
] else
Text(
message.text,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// Footer row
Row(
children: [
Icon(
Icons.access_time,
size: 12,
color: Theme.of(context).textTheme.bodySmall?.color,
),
const SizedBox(width: 4),
Text(
_formatDateTime(message.sentAt),
style: Theme.of(context).textTheme.bodySmall,
),
const Spacer(),
Text(
'Received: ${_formatDateTime(message.receivedAt)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
),
),
],
),
],
),
),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow({
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
);
}
}

View File

@@ -25,10 +25,6 @@ class _MessagesTabState extends State<MessagesTab> {
int _characterCount = 0;
static const int _maxCharacters = 160;
// Message recipient selection
String? _selectedRecipientId; // null = broadcast to public channel (channel 0)
MessageRecipientType _recipientType = MessageRecipientType.room;
@override
void initState() {
super.initState();
@@ -53,7 +49,6 @@ class _MessagesTabState extends State<MessagesTab> {
if (text.isEmpty) return;
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
@@ -67,40 +62,11 @@ class _MessagesTabState extends State<MessagesTab> {
}
try {
if (_recipientType == MessageRecipientType.contact && _selectedRecipientId != null) {
// Send to specific contact
final contact = contactsProvider.contacts.firstWhere(
(c) => c.publicKeyHex == _selectedRecipientId,
);
await connectionProvider.sendTextMessage(
contactPublicKey: contact.publicKey,
text: text,
);
} else {
// Send to room/channel
// Default to channel 0 (public channel) if no specific room selected
int channelIdx = 0;
if (_selectedRecipientId != null) {
// Try to find selected room
final rooms = contactsProvider.rooms;
try {
final targetRoom = rooms.firstWhere(
(r) => r.publicKeyHex == _selectedRecipientId,
);
if (targetRoom.outPath.isNotEmpty) {
channelIdx = targetRoom.outPath[0];
}
} catch (e) {
// Room not found, use default channel 0
}
}
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: text,
);
}
// Always send to public channel (channel 0)
await connectionProvider.sendChannelMessage(
channelIdx: 0,
text: text,
);
_textController.clear();
_focusNode.unfocus();
@@ -108,7 +74,7 @@ class _MessagesTabState extends State<MessagesTab> {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Message sent'),
content: Text('Message sent to public channel'),
backgroundColor: Colors.green,
duration: Duration(seconds: 1),
),
@@ -124,81 +90,6 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
void _showRecipientSelector() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => _RecipientSelectorSheet(
selectedRecipientId: _selectedRecipientId,
selectedRecipientType: _recipientType,
onSelect: (recipientId, recipientType) {
setState(() {
_selectedRecipientId = recipientId;
_recipientType = recipientType;
});
// Fetch messages from the newly selected channel/room
_syncMessagesForRecipient();
},
),
);
}
/// Sync all messages when recipient changes (for sending context)
Future<void> _syncMessagesForRecipient() async {
final appProvider = context.read<AppProvider>();
if (!appProvider.connectionProvider.deviceInfo.isConnected) {
return;
}
try {
debugPrint('🔄 [MessagesTab] Syncing messages after recipient change...');
final messageCount = await appProvider.syncMessages();
if (!mounted) return;
if (messageCount > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Synced $messageCount message${messageCount == 1 ? '' : 's'}'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
debugPrint('❌ [MessagesTab] Error syncing messages: $e');
}
}
String _getRecipientDisplayName() {
final contactsProvider = context.read<ContactsProvider>();
if (_selectedRecipientId == null) {
// Default to public channel
return 'Public Channel';
}
if (_recipientType == MessageRecipientType.contact) {
try {
final contact = contactsProvider.contacts.firstWhere(
(c) => c.publicKeyHex == _selectedRecipientId,
);
return contact.displayName;
} catch (e) {
return 'Public Channel';
}
} else {
try {
final room = contactsProvider.rooms.firstWhere(
(r) => r.publicKeyHex == _selectedRecipientId,
);
return room.displayName;
} catch (e) {
return 'Public Channel';
}
}
}
void _showSarDialog() {
showModalBottomSheet(
@@ -206,8 +97,8 @@ class _MessagesTabState extends State<MessagesTab> {
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => _SarUpdateSheet(
onSend: (sarType, position, notes) async {
await _sendSarMessage(sarType, position, notes);
onSend: (sarType, position, notes, channelIdx) async {
await _sendSarMessage(sarType, position, notes, channelIdx);
},
),
);
@@ -217,9 +108,9 @@ class _MessagesTabState extends State<MessagesTab> {
SarMarkerType sarType,
Position position,
String? notes,
int channelIdx,
) async {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
@@ -241,46 +132,17 @@ class _MessagesTabState extends State<MessagesTab> {
? '$sarMessage $notes'
: sarMessage;
// Send to selected recipient (contact or room)
if (_recipientType == MessageRecipientType.contact && _selectedRecipientId != null) {
// Send to specific contact
final contact = contactsProvider.contacts.firstWhere(
(c) => c.publicKeyHex == _selectedRecipientId,
);
await connectionProvider.sendTextMessage(
contactPublicKey: contact.publicKey,
text: fullMessage,
);
} else {
// Send to room/channel
// Default to channel 0 (public channel) if no specific room selected
int channelIdx = 0;
if (_selectedRecipientId != null) {
// Try to find selected room
final rooms = contactsProvider.rooms;
try {
final targetRoom = rooms.firstWhere(
(r) => r.publicKeyHex == _selectedRecipientId,
);
if (targetRoom.outPath.isNotEmpty) {
channelIdx = targetRoom.outPath[0];
}
} catch (e) {
// Room not found, use default channel 0
}
}
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: fullMessage,
);
}
// Send SAR message to selected room/channel
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: fullMessage,
);
if (!mounted) return;
final channelName = channelIdx == 0 ? 'Public Channel' : 'Channel $channelIdx';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${sarType.displayName} marker sent'),
content: Text('${sarType.displayName} marker sent to $channelName'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
@@ -393,54 +255,7 @@ class _MessagesTabState extends State<MessagesTab> {
),
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Column(
children: [
// Recipient selector bar
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: _showRecipientSelector,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
_recipientType == MessageRecipientType.contact
? Icons.person
: Icons.tag,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'To: ${_getRecipientDisplayName()}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
Icon(
Icons.arrow_drop_down,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
),
);
},
),
// Message input row
Row(
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// SAR quick action button
@@ -503,8 +318,6 @@ class _MessagesTabState extends State<MessagesTab> {
),
],
),
],
),
),
],
);
@@ -764,7 +577,7 @@ class _MessageBubble extends StatelessWidget {
// SAR Update Sheet
class _SarUpdateSheet extends StatefulWidget {
final Future<void> Function(SarMarkerType, Position, String?) onSend;
final Future<void> Function(SarMarkerType, Position, String?, int) onSend;
const _SarUpdateSheet({required this.onSend});
@@ -777,6 +590,7 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
Position? _currentPosition;
bool _loadingLocation = false;
String? _locationError;
int _selectedChannelIdx = 0; // Default to Public Channel (channel 0)
final TextEditingController _notesController = TextEditingController();
@override
@@ -942,6 +756,82 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
),
const SizedBox(height: 24),
// Room/Channel selection
const Text(
'Send To',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
// Build list of available rooms/channels
final rooms = contactsProvider.rooms;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF2D2D2D),
borderRadius: BorderRadius.circular(8),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _selectedChannelIdx,
dropdownColor: const Color(0xFF2D2D2D),
isExpanded: true,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
icon: const Icon(Icons.arrow_drop_down, color: Colors.white),
items: [
// Public Channel (always available)
const DropdownMenuItem<int>(
value: 0,
child: Row(
children: [
Icon(Icons.public, size: 18, color: Colors.white),
SizedBox(width: 12),
Text('Public Channel'),
],
),
),
// Room channels
...rooms.asMap().entries.map((entry) {
final idx = entry.key + 1; // Rooms start at channel 1
final room = entry.value;
return DropdownMenuItem<int>(
value: idx,
child: Row(
children: [
const Icon(Icons.tag, size: 18, color: Colors.white),
const SizedBox(width: 12),
Expanded(
child: Text(
room.displayName,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}).toList(),
],
onChanged: (value) {
if (value != null) {
setState(() => _selectedChannelIdx = value);
}
},
),
),
);
},
),
const SizedBox(height: 24),
// Location display
const Text(
'Current Location',
@@ -1135,6 +1025,7 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
_notesController.text.trim().isEmpty
? null
: _notesController.text.trim(),
_selectedChannelIdx,
);
if (context.mounted) {
Navigator.pop(context);
@@ -1237,210 +1128,3 @@ class _MarkerTypeChip extends StatelessWidget {
}
}
// Message recipient type enum
enum MessageRecipientType {
contact,
room,
}
// Recipient Selector Sheet
class _RecipientSelectorSheet extends StatefulWidget {
final String? selectedRecipientId;
final MessageRecipientType selectedRecipientType;
final void Function(String?, MessageRecipientType) onSelect;
const _RecipientSelectorSheet({
required this.selectedRecipientId,
required this.selectedRecipientType,
required this.onSelect,
});
@override
State<_RecipientSelectorSheet> createState() => _RecipientSelectorSheetState();
}
class _RecipientSelectorSheetState extends State<_RecipientSelectorSheet> with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(
length: 2,
vsync: this,
initialIndex: widget.selectedRecipientType == MessageRecipientType.contact ? 0 : 1,
);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
height: MediaQuery.of(context).size.height * 0.7,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Row(
children: [
Text(
'Select Recipient',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
// Tab bar
TabBar(
controller: _tabController,
tabs: const [
Tab(
icon: Icon(Icons.person),
text: 'Contacts',
),
Tab(
icon: Icon(Icons.tag),
text: 'Channels',
),
],
),
// Tab view
Expanded(
child: TabBarView(
controller: _tabController,
children: [
// Contacts tab
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final contacts = contactsProvider.chatContacts;
if (contacts.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_off, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('No contacts available'),
],
),
);
}
return ListView.builder(
itemCount: contacts.length,
itemBuilder: (context, index) {
final contact = contacts[index];
final isSelected = widget.selectedRecipientType == MessageRecipientType.contact &&
widget.selectedRecipientId == contact.publicKeyHex;
return ListTile(
leading: CircleAvatar(
child: contact.roleEmoji != null
? Text(contact.roleEmoji!)
: const Icon(Icons.person),
),
title: Text(contact.displayName),
subtitle: Text(
contact.publicKeyShort,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
trailing: isSelected
? Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
)
: null,
selected: isSelected,
onTap: () {
widget.onSelect(contact.publicKeyHex, MessageRecipientType.contact);
Navigator.pop(context);
},
);
},
);
},
),
// Channels/Rooms tab
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final rooms = contactsProvider.rooms;
if (rooms.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.tag, size: 64, color: Colors.grey),
const SizedBox(height: 16),
const Text('No channels available'),
],
),
);
}
return ListView.builder(
itemCount: rooms.length,
itemBuilder: (context, index) {
final room = rooms[index];
final isSelected = widget.selectedRecipientType == MessageRecipientType.room &&
widget.selectedRecipientId == room.publicKeyHex;
return ListTile(
leading: const CircleAvatar(
child: Icon(Icons.tag),
),
title: Text(room.displayName),
subtitle: Text(
room.publicKeyShort,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
trailing: isSelected
? Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
)
: null,
selected: isSelected,
onTap: () {
widget.onSelect(room.publicKeyHex, MessageRecipientType.room);
Navigator.pop(context);
},
);
},
);
},
),
],
),
),
],
),
);
}
}