Refactor map tab and messages tab UI; enhance settings screen with sample data loading and clearing functionality; improve map markers display

This commit is contained in:
Janez T
2025-10-14 00:15:45 +02:00
parent 76b4f18564
commit c2e882bb2b
5 changed files with 920 additions and 128 deletions

View File

@@ -1,68 +1,431 @@
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 '../utils/sar_message_parser.dart';
import '../models/contact.dart';
class MessagesTab extends StatelessWidget {
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);
if (messages.isEmpty) {
return 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,
),
],
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,
);
},
),
),
);
}
return 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,
);
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,
),
),
],
),
],
),
),
],
);
},
);