Files
meshcore-sar_android/lib/screens/messages_tab.dart

569 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/messages_provider.dart';
import '../providers/contacts_provider.dart';
import '../providers/map_provider.dart';
import '../providers/connection_provider.dart';
import '../models/message.dart';
import '../models/contact.dart';
class MessagesTab extends StatefulWidget {
final VoidCallback onNavigateToMap;
const MessagesTab({super.key, required this.onNavigateToMap});
@override
State<MessagesTab> createState() => _MessagesTabState();
}
class _MessagesTabState extends State<MessagesTab> {
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
Contact? _selectedContact;
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> _sendMessage() 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;
}
if (_selectedContact == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select a recipient'),
backgroundColor: Colors.orange,
),
);
return;
}
try {
// Send to channel/room or direct contact
if (_selectedContact!.isRoom) {
// For rooms/channels, use the first byte of outPath as channel index
final channelIdx = _selectedContact!.outPath.isNotEmpty
? _selectedContact!.outPath[0]
: 0;
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: text,
);
} else {
// For direct contacts (chat type)
await connectionProvider.sendTextMessage(
contactPublicKey: _selectedContact!.publicKey,
text: text,
);
}
_textController.clear();
_focusNode.unfocus();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Message sent'),
backgroundColor: Colors.green,
duration: Duration(seconds: 1),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to send: $e'),
backgroundColor: Colors.red,
),
);
}
}
void _showContactSelector() {
final contactsProvider = context.read<ContactsProvider>();
final allContacts = contactsProvider.contacts;
if (allContacts.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No contacts available'),
backgroundColor: Colors.orange,
),
);
return;
}
// Group contacts by type
final chatContacts = contactsProvider.chatContacts;
final rooms = contactsProvider.rooms;
final repeaters = contactsProvider.repeaters;
showModalBottomSheet(
context: context,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Select Recipient',
style: Theme.of(context).textTheme.titleLarge,
),
),
const Divider(height: 1),
Flexible(
child: ListView(
shrinkWrap: true,
children: [
// Chat contacts section
if (chatContacts.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Team Members',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
...chatContacts.map((contact) => _buildContactTile(
contact: contact,
icon: Icons.person,
color: Colors.blue,
)),
],
// Rooms/Channels section
if (rooms.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Channels',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
...rooms.map((contact) => _buildContactTile(
contact: contact,
icon: Icons.tag,
color: Colors.purple,
)),
],
// Repeaters section (informational only)
if (repeaters.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Repeaters (Read-only)',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Colors.grey,
),
),
),
...repeaters.map((contact) => ListTile(
enabled: false,
leading: CircleAvatar(
backgroundColor: Colors.grey,
child: const Icon(Icons.router, color: Colors.white, size: 20),
),
title: Text(contact.advName),
subtitle: Text(contact.timeSinceLastSeen),
)),
],
],
),
),
],
),
);
}
Widget _buildContactTile({
required Contact contact,
required IconData icon,
required Color color,
}) {
return ListTile(
leading: CircleAvatar(
backgroundColor: color,
child: Icon(icon, color: Colors.white, size: 20),
),
title: Text(contact.advName),
subtitle: Text(contact.timeSinceLastSeen),
trailing: _selectedContact?.publicKeyHex == contact.publicKeyHex
? const Icon(Icons.check_circle, color: Colors.green)
: null,
onTap: () {
setState(() {
_selectedContact = contact;
});
Navigator.pop(context);
},
);
}
@override
Widget build(BuildContext context) {
return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) {
final messages = messagesProvider.getRecentMessages(count: 100);
return Column(
children: [
// Messages list
Expanded(
child: messages.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.message_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
'No messages yet',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Connect to a device to start receiving messages',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
)
: ListView.builder(
reverse: true,
padding: const EdgeInsets.all(8),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
return _MessageBubble(
message: message,
onTap: message.isSarMarker &&
message.sarGpsCoordinates != null
? () {
final mapProvider =
context.read<MapProvider>();
mapProvider.navigateToLocation(
location: message.sarGpsCoordinates!,
zoom: 15.0,
);
widget.onNavigateToMap();
}
: null,
);
},
),
),
// Message input area
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Contact selector (compact)
if (_selectedContact != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
_selectedContact!.isRoom
? Icons.tag
: Icons.person,
size: 14,
),
const SizedBox(width: 6),
Expanded(
child: Text(
_selectedContact!.advName,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 12,
),
overflow: TextOverflow.ellipsis,
),
),
GestureDetector(
onTap: () {
setState(() {
_selectedContact = null;
});
},
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(Icons.close, size: 14),
),
),
],
),
),
// Text input row
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Contact selector button (compact)
IconButton(
icon: const Icon(Icons.contacts, size: 20),
onPressed: _showContactSelector,
tooltip: 'Select contact',
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
),
// Text field (compact)
Expanded(
child: TextField(
controller: _textController,
focusNode: _focusNode,
maxLength: _maxCharacters,
maxLines: null,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: 'Message...',
hintStyle: const TextStyle(fontSize: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
isDense: true,
counterText: '$_characterCount/$_maxCharacters',
counterStyle: TextStyle(
fontSize: 10,
color: _characterCount > _maxCharacters * 0.9
? Colors.orange
: Theme.of(context).textTheme.bodySmall?.color,
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
),
// Send button (compact)
IconButton(
icon: const Icon(Icons.send, size: 20),
onPressed: _textController.text.trim().isEmpty
? null
: _sendMessage,
color: Theme.of(context).colorScheme.primary,
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
),
],
),
],
),
),
],
);
},
);
}
}
class _MessageBubble extends StatelessWidget {
final Message message;
final VoidCallback? onTap;
const _MessageBubble({
required this.message,
this.onTap,
});
@override
Widget build(BuildContext context) {
final isSarMarker = message.isSarMarker;
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSarMarker
? _getSarMarkerColor(context)
: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12),
border: isSarMarker
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Sender and time
Row(
children: [
if (message.isChannelMessage)
const Icon(Icons.tag, size: 16)
else
const Icon(Icons.person, size: 16),
const SizedBox(width: 4),
Text(
message.displaySender,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (isSarMarker)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'SAR',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
Text(
message.timeAgo,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
const SizedBox(height: 8),
// SAR marker content
if (isSarMarker && message.sarMarkerType != null) ...[
Row(
children: [
Text(
message.sarMarkerType!.emoji,
style: const TextStyle(fontSize: 32),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message.sarMarkerType!.displayName,
style:
Theme.of(context).textTheme.titleMedium?.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,
),
],
),
),
Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.primary,
),
],
),
const SizedBox(height: 4),
Text(
'Tap to view on map',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontStyle: FontStyle.italic,
),
),
]
// Regular message content
else
Text(
message.text,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
}
Color _getSarMarkerColor(BuildContext context) {
return Theme.of(context).colorScheme.primaryContainer;
}
}