mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
422 lines
14 KiB
Dart
422 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../providers/contacts_provider.dart';
|
|
import '../providers/connection_provider.dart';
|
|
import '../models/contact.dart';
|
|
|
|
class ContactsTab extends StatelessWidget {
|
|
const ContactsTab({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<ContactsProvider>(
|
|
builder: (context, contactsProvider, child) {
|
|
final chatContacts = contactsProvider.chatContacts;
|
|
final repeaters = contactsProvider.repeaters;
|
|
final rooms = contactsProvider.rooms;
|
|
|
|
if (contactsProvider.contacts.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.contacts_outlined,
|
|
size: 64,
|
|
color: Theme.of(context).disabledColor,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No contacts yet',
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Connect to a device and refresh to load contacts',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.all(8),
|
|
children: [
|
|
// Team Members (Chat contacts)
|
|
if (chatContacts.isNotEmpty) ...[
|
|
_SectionHeader(
|
|
title: 'Team Members',
|
|
count: chatContacts.length,
|
|
icon: Icons.people,
|
|
),
|
|
...chatContacts.map((contact) => _ContactTile(contact: contact)),
|
|
const Divider(height: 32),
|
|
],
|
|
|
|
// Repeaters
|
|
if (repeaters.isNotEmpty) ...[
|
|
_SectionHeader(
|
|
title: 'Repeaters',
|
|
count: repeaters.length,
|
|
icon: Icons.router,
|
|
),
|
|
...repeaters.map((contact) => _ContactTile(contact: contact)),
|
|
const Divider(height: 32),
|
|
],
|
|
|
|
// Rooms/Channels
|
|
if (rooms.isNotEmpty) ...[
|
|
_SectionHeader(
|
|
title: 'Rooms/Channels',
|
|
count: rooms.length,
|
|
icon: Icons.tag,
|
|
),
|
|
...rooms.map((contact) => _ContactTile(contact: contact)),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionHeader extends StatelessWidget {
|
|
final String title;
|
|
final int count;
|
|
final IconData icon;
|
|
|
|
const _SectionHeader({
|
|
required this.title,
|
|
required this.count,
|
|
required this.icon,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 20),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
count.toString(),
|
|
style: Theme.of(context).textTheme.labelSmall,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ContactTile extends StatelessWidget {
|
|
final Contact contact;
|
|
|
|
const _ContactTile({required this.contact});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final hasTelemetry = contact.telemetry != null && contact.telemetry!.isRecent;
|
|
final battery = contact.displayBattery;
|
|
final location = contact.displayLocation;
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
child: ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: _getTypeColor(contact.type, context),
|
|
child: contact.roleEmoji != null
|
|
? Text(
|
|
contact.roleEmoji!,
|
|
style: const TextStyle(fontSize: 24),
|
|
)
|
|
: Icon(
|
|
_getTypeIcon(contact.type),
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
title: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
contact.displayName,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
// Battery indicator
|
|
if (battery != null) ...[
|
|
Icon(
|
|
_getBatteryIcon(battery),
|
|
size: 16,
|
|
color: _getBatteryColor(battery),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${battery.round()}%',
|
|
style: Theme.of(context).textTheme.labelSmall,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 4),
|
|
// Type and last seen
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: _getTypeColor(contact.type, context).withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
contact.type.displayName,
|
|
style: Theme.of(context).textTheme.labelSmall,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Icon(
|
|
Icons.access_time,
|
|
size: 12,
|
|
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
contact.timeSinceLastSeen,
|
|
style: Theme.of(context).textTheme.labelSmall,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
// Telemetry info
|
|
Row(
|
|
children: [
|
|
if (hasTelemetry)
|
|
const Icon(Icons.sensors, size: 12, color: Colors.green)
|
|
else
|
|
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
|
|
const SizedBox(width: 4),
|
|
if (location != null)
|
|
Expanded(
|
|
child: Text(
|
|
'GPS: ${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}',
|
|
style: Theme.of(context).textTheme.labelSmall,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
)
|
|
else
|
|
Text(
|
|
'No GPS data',
|
|
style: Theme.of(context).textTheme.labelSmall,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
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),
|
|
),
|
|
);
|
|
},
|
|
tooltip: 'Request telemetry',
|
|
),
|
|
onTap: () => _showContactDetails(context, contact),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showContactDetails(BuildContext context, Contact contact) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (context) => DraggableScrollableSheet(
|
|
initialChildSize: 0.6,
|
|
minChildSize: 0.4,
|
|
maxChildSize: 0.9,
|
|
expand: false,
|
|
builder: (context, scrollController) => Column(
|
|
children: [
|
|
// Handle bar
|
|
Container(
|
|
margin: const EdgeInsets.only(top: 8, bottom: 16),
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[300],
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
// Header
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
CircleAvatar(
|
|
backgroundColor: _getTypeColor(contact.type, context),
|
|
child: contact.roleEmoji != null
|
|
? Text(
|
|
contact.roleEmoji!,
|
|
style: const TextStyle(fontSize: 24),
|
|
)
|
|
: Icon(
|
|
_getTypeIcon(contact.type),
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
contact.displayName,
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(),
|
|
// Content
|
|
Expanded(
|
|
child: ListView(
|
|
controller: scrollController,
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
_DetailRow('Type', contact.type.displayName),
|
|
_DetailRow('Public Key', contact.publicKeyShort),
|
|
_DetailRow('Last Seen', contact.timeSinceLastSeen),
|
|
const SizedBox(height: 16),
|
|
if (contact.displayLocation != null) ...[
|
|
const Text(
|
|
'Location:',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_DetailRow('Latitude', contact.displayLocation!.latitude.toStringAsFixed(6)),
|
|
_DetailRow('Longitude', contact.displayLocation!.longitude.toStringAsFixed(6)),
|
|
const SizedBox(height: 16),
|
|
],
|
|
if (contact.telemetry != null) ...[
|
|
const Text(
|
|
'Telemetry:',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (contact.telemetry!.batteryPercentage != null)
|
|
_DetailRow('Battery', '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%'),
|
|
if (contact.telemetry!.temperature != null)
|
|
_DetailRow('Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'),
|
|
_DetailRow('Updated', contact.telemetry!.isRecent ? 'Recently' : 'Stale'),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _DetailRow(String label, String value) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 100,
|
|
child: Text(
|
|
'$label:',
|
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(value),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
IconData _getTypeIcon(ContactType type) {
|
|
switch (type) {
|
|
case ContactType.chat:
|
|
return Icons.person;
|
|
case ContactType.repeater:
|
|
return Icons.router;
|
|
case ContactType.room:
|
|
return Icons.tag;
|
|
default:
|
|
return Icons.help;
|
|
}
|
|
}
|
|
|
|
Color _getTypeColor(ContactType type, BuildContext context) {
|
|
switch (type) {
|
|
case ContactType.chat:
|
|
return Theme.of(context).colorScheme.primary;
|
|
case ContactType.repeater:
|
|
return Colors.green;
|
|
case ContactType.room:
|
|
return Colors.orange;
|
|
default:
|
|
return Colors.grey;
|
|
}
|
|
}
|
|
|
|
IconData _getBatteryIcon(double percentage) {
|
|
if (percentage > 80) return Icons.battery_full;
|
|
if (percentage > 50) return Icons.battery_5_bar;
|
|
if (percentage > 20) return Icons.battery_3_bar;
|
|
return Icons.battery_1_bar;
|
|
}
|
|
|
|
Color _getBatteryColor(double percentage) {
|
|
if (percentage > 50) return Colors.green;
|
|
if (percentage > 20) return Colors.orange;
|
|
return Colors.red;
|
|
}
|
|
}
|