mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
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:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user