Files
meshcore-sar_android/lib/screens/contacts_tab.dart
Janez T fa2b586ab7 i18n: Add 247 missing translation keys across all 13 languages
Replace hardcoded English strings in 32 Dart source files with l10n
references. Covers settings labels, UI actions, map/SAR features,
device config, notifications, profiles, spectrum scan, and status
messages for sl, de, hr, pl, es, fr, it, el, ru, tr, uk, zh, pt.
2026-03-17 10:36:58 +01:00

1840 lines
62 KiB
Dart

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import '../l10n/app_localizations.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import '../providers/contacts_provider.dart';
import '../providers/app_provider.dart';
import '../providers/connection_provider.dart';
import '../providers/map_provider.dart';
import '../providers/messages_provider.dart';
import '../services/message_destination_preferences.dart';
import '../utils/contact_grouping.dart';
import '../utils/avatar_label_helper.dart';
import '../widgets/common/contact_avatar.dart';
import '../widgets/contacts/contact_tile.dart';
import '../widgets/contacts/add_channel_dialog.dart';
import 'add_contact_screen.dart';
class ContactsTab extends StatefulWidget {
final VoidCallback? onNavigateToMap;
final VoidCallback? onNavigateToMessages;
const ContactsTab({
super.key,
this.onNavigateToMap,
this.onNavigateToMessages,
});
@override
State<ContactsTab> createState() => _ContactsTabState();
}
class _ContactsTabState extends State<ContactsTab> {
Position? _currentPosition;
final Map<ContactSection, String> _sectionFilters = {
ContactSection.teamMembers: '',
ContactSection.repeaters: '',
ContactSection.sensors: '',
ContactSection.rooms: '',
ContactSection.channels: '',
};
late final Map<ContactSection, TextEditingController> _filterControllers;
final Map<ContactSection, ContactSortMode> _sortModes = {
ContactSection.teamMembers: ContactSortMode.lastSeen,
ContactSection.repeaters: ContactSortMode.lastSeen,
ContactSection.sensors: ContactSortMode.lastSeen,
ContactSection.rooms: ContactSortMode.lastSeen,
};
@override
void initState() {
super.initState();
_filterControllers = {
for (final section in ContactSection.values)
section: TextEditingController(text: _sectionFilters[section] ?? ''),
};
_getCurrentLocation();
// Mark all contacts as viewed when tab is opened
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ContactsProvider>().markAllAsViewed();
});
}
@override
void dispose() {
for (final controller in _filterControllers.values) {
controller.dispose();
}
super.dispose();
}
Future<void> _getCurrentLocation() async {
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 0,
),
);
if (mounted) {
setState(() {
_currentPosition = position;
});
}
} catch (e) {
// Silently fail if location not available
debugPrint('Failed to get location: $e');
}
}
Future<void> _handleRefresh() async {
if (!mounted) return;
final appProvider = context.read<AppProvider>();
await appProvider.refresh();
// Also refresh location
if (!mounted) return;
await _getCurrentLocation();
}
/// Calculate distance between two points in meters
double _calculateDistanceInMeters(
double lat1,
double lon1,
double lat2,
double lon2,
) {
const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180;
final a =
sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLon / 2) *
sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return R * c;
}
/// Format distance for display
String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.round()}m';
} else if (meters < 10000) {
return '${(meters / 1000).toStringAsFixed(2)}km';
} else {
return '${(meters / 1000).toStringAsFixed(1)}km';
}
}
List<Contact> _filterContactsForSection(
List<Contact> contacts,
ContactSection section,
) {
final query = (_sectionFilters[section] ?? '').trim().toLowerCase();
if (query.isEmpty) {
return contacts;
}
return contacts.where((contact) {
final name = contact.displayName.toLowerCase();
final advertisedName = contact.advName.toLowerCase();
return name.contains(query) ||
advertisedName.contains(query) ||
ContactGrouping.contactMatchesInferredGroupLabel(contact, query);
}).toList();
}
bool _contactMatchesFilter(Contact contact, String query) {
final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) {
return true;
}
final name = contact.displayName.toLowerCase();
final advertisedName = contact.advName.toLowerCase();
return name.contains(normalizedQuery) ||
advertisedName.contains(normalizedQuery) ||
ContactGrouping.contactMatchesInferredGroupLabel(
contact,
normalizedQuery,
);
}
bool _sectionHasActiveFilter(ContactSection section) {
return (_sectionFilters[section] ?? '').trim().isNotEmpty;
}
bool _showSavedGroupsForSection(ContactSection section) {
return !_sectionHasActiveFilter(section);
}
List<_RenderedSavedGroup> _buildSavedGroupsForSection(
ContactsProvider contactsProvider,
List<Contact> contacts,
ContactSection section,
) {
return contactsProvider
.savedGroupsForSection(section.name)
.map((group) {
final matches = contacts
.where((contact) => _contactMatchesSavedGroup(contact, group))
.toList();
return _RenderedSavedGroup(group: group, contacts: matches);
})
.where((group) => group.contacts.isNotEmpty)
.toList()
..sort(
(a, b) => b.contacts.first.lastSeenTime.compareTo(
a.contacts.first.lastSeenTime,
),
);
}
bool _contactMatchesSavedGroup(Contact contact, SavedContactGroup group) {
final matchPrefixes = group.matchPrefixes;
if (matchPrefixes != null && matchPrefixes.isNotEmpty) {
final inferredLabel = ContactGrouping.inferredGroupLabelForContact(
contact,
)?.toLowerCase();
if (inferredLabel == null) {
return false;
}
return matchPrefixes.any(
(prefix) => prefix.toLowerCase() == inferredLabel,
);
}
return _contactMatchesFilter(contact, group.query);
}
Future<void> _toggleSavedGroupForSection(
BuildContext context,
ContactsProvider contactsProvider,
ContactSection section,
) async {
final filter = (_sectionFilters[section] ?? '').trim();
if (filter.isEmpty) {
return;
}
final alreadySaved = contactsProvider.hasSavedGroupForFilter(
section.name,
filter,
);
if (alreadySaved) {
await contactsProvider.removeSavedGroupForFilter(section.name, filter);
} else {
await contactsProvider.addSavedGroupForFilter(
section.name,
filter,
label: filter,
);
}
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
alreadySaved
? 'Removed saved group "$filter"'
: 'Saved group "$filter"',
),
),
);
}
Future<void> _createAutoGroupsForSection(
BuildContext context,
ContactsProvider contactsProvider,
ContactSection section,
List<Contact> contacts, {
int? maxNamedGroups,
String? overflowGroupLabel,
String emptyMessage = 'No auto groups available',
String successMessage = 'Updated auto groups',
}) async {
final inferredGroups = ContactGrouping.inferGroups(
contacts,
maxNamedGroups: maxNamedGroups,
overflowGroupLabel: overflowGroupLabel,
);
final now = DateTime.now();
final groups = inferredGroups
.map(
(group) => SavedContactGroup(
id: '${ContactsProvider.autoGroupIdPrefix}${section.name}_${group.key}',
sectionKey: section.name,
label: group.label,
query: group.label,
createdAt: now,
matchPrefixes: group.matchPrefixes,
isAutoGroup: true,
),
)
.toList();
await contactsProvider.replaceAutoGroupsForSection(section.name, groups);
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(groups.isEmpty ? emptyMessage : successMessage)),
);
}
List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
final sorted = List<Contact>.from(contacts);
if (section == ContactSection.channels) {
sorted.sort(
(a, b) =>
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
);
return sorted;
}
final sortMode = _sortModes[section] ?? ContactSortMode.lastSeen;
sorted.sort((a, b) {
if (sortMode == ContactSortMode.distance) {
final distanceA = _distanceFromCurrentPosition(a);
final distanceB = _distanceFromCurrentPosition(b);
if (distanceA != null && distanceB != null) {
final distanceCompare = distanceA.compareTo(distanceB);
if (distanceCompare != 0) return distanceCompare;
} else if (distanceA != null) {
return -1;
} else if (distanceB != null) {
return 1;
}
}
return b.lastSeenTime.compareTo(a.lastSeenTime);
});
return sorted;
}
double? _distanceFromCurrentPosition(Contact contact) {
final currentPosition = _currentPosition;
final contactLocation = contact.displayLocation;
if (currentPosition == null || contactLocation == null) {
return null;
}
return _calculateDistanceInMeters(
currentPosition.latitude,
currentPosition.longitude,
contactLocation.latitude,
contactLocation.longitude,
);
}
/// Show the add channel sheet
Future<void> _showAddChannelDialog(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: AddChannelSheet(
onCreateChannel: (name, secret) async {
final connectionProvider = context.read<ConnectionProvider>();
try {
await connectionProvider.createChannel(
channelName: name,
channelSecret: secret,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.channelCreatedSuccessfully),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.channelCreationFailed(e.toString())),
backgroundColor: Colors.red,
),
);
}
rethrow; // Re-throw to let dialog handle the error state
}
},
),
),
),
);
}
Future<void> _openAddContactScreen(BuildContext context) async {
await Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => const AddContactScreen()));
}
Future<void> _showDeleteChannelDialog(
BuildContext context,
Contact channel,
) async {
if (channel.isPublicChannel) {
return;
}
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.deleteChannel),
content: Text(l10n.deleteChannelConfirmation(channel.advName)),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(l10n.delete),
),
],
),
);
if (confirmed != true || !context.mounted) {
return;
}
try {
final channelIdx = channel.publicKey.length > 1
? channel.publicKey[1]
: 0;
await context.read<ConnectionProvider>().deleteChannel(channelIdx);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.channelDeletedSuccessfully),
backgroundColor: Colors.green,
),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.channelDeletionFailed(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
Future<void> _openMessagesForChannel(
BuildContext context,
Contact channel,
) async {
final messagesProvider = context.read<MessagesProvider>();
await MessageDestinationPreferences.setDestination(
MessageDestinationPreferences.destinationTypeChannel,
recipientPublicKey: channel.publicKeyHex,
);
messagesProvider.navigateToDestination(
MessageDestinationPreferences.destinationTypeChannel,
recipientPublicKeyHex: channel.publicKeyHex,
);
widget.onNavigateToMessages?.call();
}
void _showChannelOnMap(BuildContext context, Contact channel) {
final location = channel.displayLocation;
if (location == null) {
return;
}
context.read<MapProvider>().navigateToLocation(
location: LatLng(location.latitude, location.longitude),
);
widget.onNavigateToMap?.call();
}
void _showChannelActionSheet(BuildContext context, Contact channel) {
final l10n = AppLocalizations.of(context)!;
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(Icons.message_outlined),
title: Text(l10n.messages),
onTap: () async {
Navigator.pop(sheetContext);
await _openMessagesForChannel(context, channel);
},
),
if (channel.displayLocation != null)
ListTile(
leading: Icon(Icons.map_outlined),
title: Text(l10n.viewOnMap),
onTap: () {
Navigator.pop(sheetContext);
_showChannelOnMap(context, channel);
},
),
if (!channel.isPublicChannel)
ListTile(
leading: Icon(Icons.delete, color: Colors.red),
title: Text(
l10n.deleteChannel,
style: const TextStyle(color: Colors.red),
),
onTap: () async {
Navigator.pop(sheetContext);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
_showDeleteChannelDialog(context, channel);
});
},
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final messagesProvider = context.watch<MessagesProvider>();
final allChatContacts = _sortContacts(
contactsProvider.chatContacts,
ContactSection.teamMembers,
);
final allRepeaters = _sortContacts(
contactsProvider.repeaters,
ContactSection.repeaters,
);
final allSensors = _sortContacts(
contactsProvider.sensorContacts,
ContactSection.sensors,
);
final allRooms = _sortContacts(
contactsProvider.rooms,
ContactSection.rooms,
);
final allChannels = _sortContacts(
contactsProvider.channels,
ContactSection.channels,
);
final chatContacts = _filterContactsForSection(
allChatContacts,
ContactSection.teamMembers,
);
final savedTeamGroups = _buildSavedGroupsForSection(
contactsProvider,
allChatContacts,
ContactSection.teamMembers,
);
final visibleSavedTeamGroups =
_showSavedGroupsForSection(ContactSection.teamMembers)
? savedTeamGroups
: const <_RenderedSavedGroup>[];
final repeaters = _filterContactsForSection(
allRepeaters,
ContactSection.repeaters,
);
final savedRepeaterGroups = _buildSavedGroupsForSection(
contactsProvider,
allRepeaters,
ContactSection.repeaters,
);
final visibleSavedRepeaterGroups =
_showSavedGroupsForSection(ContactSection.repeaters)
? savedRepeaterGroups
: const <_RenderedSavedGroup>[];
final ungroupedRepeaters = _excludeGroupedContacts(
repeaters,
visibleSavedRepeaterGroups,
);
final showRepeatersOthersGroup =
visibleSavedRepeaterGroups.length > 1 &&
ungroupedRepeaters.isNotEmpty;
final sensors = _filterContactsForSection(
allSensors,
ContactSection.sensors,
);
final savedSensorGroups = _buildSavedGroupsForSection(
contactsProvider,
allSensors,
ContactSection.sensors,
);
final visibleSavedSensorGroups =
_showSavedGroupsForSection(ContactSection.sensors)
? savedSensorGroups
: const <_RenderedSavedGroup>[];
final rooms = _filterContactsForSection(
allRooms,
ContactSection.rooms,
);
final savedRoomGroups = _buildSavedGroupsForSection(
contactsProvider,
allRooms,
ContactSection.rooms,
);
final visibleSavedRoomGroups =
_showSavedGroupsForSection(ContactSection.rooms)
? savedRoomGroups
: const <_RenderedSavedGroup>[];
final filteredChannels = _filterContactsForSection(
allChannels,
ContactSection.channels,
);
final savedChannelGroups = _buildSavedGroupsForSection(
contactsProvider,
allChannels,
ContactSection.channels,
);
final visibleSavedChannelGroups =
_showSavedGroupsForSection(ContactSection.channels)
? savedChannelGroups
: const <_RenderedSavedGroup>[];
final showTeamMembersSection = allChatContacts.isNotEmpty;
final showRepeatersSection = allRepeaters.isNotEmpty;
final showSensorsSection = allSensors.isNotEmpty;
final showRoomsSection = allRooms.isNotEmpty;
final showChannelsSection = allChannels.isNotEmpty;
// Check if there are any displayable contacts
final hasDisplayableContacts =
allChatContacts.isNotEmpty ||
allRepeaters.isNotEmpty ||
allSensors.isNotEmpty ||
allRooms.isNotEmpty ||
allChannels.isNotEmpty;
if (!hasDisplayableContacts) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.contacts_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
SizedBox(height: 16),
Text(
l10n.noContactsYet,
style: Theme.of(context).textTheme.titleLarge,
),
SizedBox(height: 8),
Text(
l10n.connectToDeviceToLoadContacts,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
if (context
.watch<ConnectionProvider>()
.deviceInfo
.isConnected)
Padding(
padding: const EdgeInsets.only(top: 16),
child: OutlinedButton.icon(
onPressed: () => _openAddContactScreen(context),
icon: Icon(Icons.person_add_alt_1_outlined),
label: Text(l10n.addContact),
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: _handleRefresh,
child: ListView(
padding: const EdgeInsets.all(8),
children: [
// Favourites (contacts with firmware favourite flag set)
if (contactsProvider.favouriteContacts.isNotEmpty) ...[
_SectionHeader(
title: l10n.favourites,
count: contactsProvider.favouriteContacts.length,
icon: Icons.star,
),
..._buildContactSectionItems(
contactsProvider.favouriteContacts,
),
const Divider(height: 32),
],
// Team Members (Chat contacts)
if (showTeamMembersSection) ...[
_SectionHeader(
title: l10n.teamMembers,
count: chatContacts.length,
icon: Icons.people,
trailing: _buildSortMenu(
context,
ContactSection.teamMembers,
),
),
_buildSectionFilterField(
context,
ContactSection.teamMembers,
contactsProvider,
onSecondaryAction: () => _createAutoGroupsForSection(
context,
contactsProvider,
ContactSection.teamMembers,
allChatContacts,
emptyMessage: 'No contact auto groups available',
successMessage: 'Updated contact auto groups',
),
secondaryActionIcon: Icons.auto_awesome_outlined,
secondaryActionTooltip: 'Auto group',
),
..._buildSavedGroupCards(
visibleSavedTeamGroups,
ContactSection.teamMembers,
),
if (chatContacts.isEmpty &&
_sectionHasActiveFilter(ContactSection.teamMembers))
_buildNoFilterResults(context)
else
..._buildContactSectionItems(
_excludeGroupedContacts(
chatContacts,
visibleSavedTeamGroups,
),
),
const Divider(height: 32),
],
// Repeaters
if (showRepeatersSection) ...[
_SectionHeader(
title: l10n.repeaters,
count: repeaters.length,
icon: Icons.router,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (context
.watch<ConnectionProvider>()
.deviceInfo
.isConnected)
IconButton(
icon: const Icon(Icons.radar, size: 20),
tooltip: 'Discover repeaters',
visualDensity: VisualDensity.compact,
onPressed: () {
context
.read<ConnectionProvider>()
.discoverNodeType(advertType: 2);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.repeaterDiscoverySent),
),
);
},
),
_buildSortMenu(context, ContactSection.repeaters),
],
),
),
_buildSectionFilterField(
context,
ContactSection.repeaters,
contactsProvider,
onSecondaryAction: () => _createAutoGroupsForSection(
context,
contactsProvider,
ContactSection.repeaters,
allRepeaters,
maxNamedGroups: 2,
overflowGroupLabel: 'Others',
emptyMessage: 'No repeater auto groups available',
successMessage: 'Updated repeater auto groups',
),
secondaryActionIcon: Icons.auto_awesome_outlined,
secondaryActionTooltip: 'Auto group',
),
..._buildSavedGroupCards(
visibleSavedRepeaterGroups,
ContactSection.repeaters,
),
if (repeaters.isEmpty &&
_sectionHasActiveFilter(ContactSection.repeaters))
_buildNoFilterResults(context)
else if (showRepeatersOthersGroup)
_InferredContactGroupCard(
label: l10n.others,
contacts: ungroupedRepeaters,
compactContacts: true,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
)
else
..._buildContactSectionItems(
ungroupedRepeaters,
compact: true,
),
const Divider(height: 32),
],
// Sensors
if (showSensorsSection) ...[
_SectionHeader(
title: l10n.sensors,
count: sensors.length,
icon: Icons.sensors,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (context
.watch<ConnectionProvider>()
.deviceInfo
.isConnected)
IconButton(
icon: const Icon(Icons.radar, size: 20),
tooltip: 'Discover sensors',
visualDensity: VisualDensity.compact,
onPressed: () {
context
.read<ConnectionProvider>()
.discoverNodeType(advertType: 4);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.sensorDiscoverySent),
),
);
},
),
_buildSortMenu(context, ContactSection.sensors),
],
),
),
_buildSectionFilterField(
context,
ContactSection.sensors,
contactsProvider,
),
..._buildSavedGroupCards(
visibleSavedSensorGroups,
ContactSection.sensors,
),
if (sensors.isEmpty &&
_sectionHasActiveFilter(ContactSection.sensors))
_buildNoFilterResults(context)
else
..._buildContactSectionItems(
_excludeGroupedContacts(
sensors,
visibleSavedSensorGroups,
),
),
const Divider(height: 32),
],
// Rooms
if (showRoomsSection) ...[
_SectionHeader(
title: l10n.rooms,
count: rooms.length,
icon: Icons.tag,
trailing: _buildSortMenu(context, ContactSection.rooms),
),
_buildSectionFilterField(
context,
ContactSection.rooms,
contactsProvider,
),
..._buildSavedGroupCards(
visibleSavedRoomGroups,
ContactSection.rooms,
),
if (rooms.isEmpty &&
_sectionHasActiveFilter(ContactSection.rooms))
_buildNoFilterResults(context)
else
..._buildContactSectionItems(
_excludeGroupedContacts(rooms, visibleSavedRoomGroups),
),
const Divider(height: 32),
],
// Channels (visible in both simple and advanced mode)
if (showChannelsSection) ...[
_SectionHeader(
title: l10n.channels,
count: filteredChannels.length,
icon: Icons.broadcast_on_personal,
),
_buildSectionFilterField(
context,
ContactSection.channels,
contactsProvider,
),
..._buildSavedGroupCards(
visibleSavedChannelGroups,
ContactSection.channels,
),
if (filteredChannels.isEmpty &&
_sectionHasActiveFilter(ContactSection.channels))
_buildNoFilterResults(context)
else ...[
..._excludeGroupedContacts(
filteredChannels,
visibleSavedChannelGroups,
).map(
(channel) => _ChannelActivityCard(
channel: channel,
messagesProvider: messagesProvider,
contactsProvider: contactsProvider,
onTap: () => _showChannelActionSheet(context, channel),
),
),
],
],
// Add Channel Button (visible in both simple and advanced mode, only show when connected)
if (context.watch<ConnectionProvider>().deviceInfo.isConnected)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _openAddContactScreen(context),
icon: Icon(Icons.person_add_alt_1_outlined),
label: Text(l10n.addContact),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _showAddChannelDialog(context),
icon: Icon(Icons.add_circle_outline),
label: Text(l10n.addChannel),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
),
],
),
),
],
),
);
},
),
);
}
Widget _buildNoFilterResults(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
child: Text(
'No matches',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
List<Widget> _buildContactSectionItems(
List<Contact> contacts, {
bool compact = false,
}) {
return contacts
.map(
(contact) => ContactTile(
contact: contact,
compact: compact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
),
)
.toList();
}
List<Contact> _excludeGroupedContacts(
List<Contact> contacts,
List<_RenderedSavedGroup> savedGroups,
) {
final groupedKeys = savedGroups
.expand(
(group) => group.contacts.map((contact) => contact.publicKeyHex),
)
.toSet();
return contacts
.where((contact) => !groupedKeys.contains(contact.publicKeyHex))
.toList();
}
List<Widget> _buildSavedGroupCards(
List<_RenderedSavedGroup> groups,
ContactSection section,
) {
return groups
.map(
(group) => _InferredContactGroupCard(
label: group.group.label,
contacts: group.contacts,
compactContacts: section == ContactSection.repeaters,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
onDelete: () => context
.read<ContactsProvider>()
.removeSavedGroupById(group.group.id),
),
)
.toList();
}
Widget _buildSectionFilterField(
BuildContext context,
ContactSection section,
ContactsProvider contactsProvider, {
VoidCallback? onSecondaryAction,
IconData? secondaryActionIcon,
String? secondaryActionTooltip,
}) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final controller = _filterControllers[section]!;
final hasFilter = (_sectionFilters[section] ?? '').isNotEmpty;
final isSavedFilter = hasFilter
? contactsProvider.hasSavedGroupForFilter(
section.name,
_sectionFilters[section] ?? '',
)
: false;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: Colors.transparent,
child: Ink(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: hasFilter
? colorScheme.primary.withValues(alpha: 0.38)
: colorScheme.outline.withValues(alpha: 0.32),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.025),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SizedBox(
height: 42,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 12, right: 8),
child: Icon(
Icons.search_rounded,
size: 17,
color: hasFilter
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
Expanded(
child: TextFormField(
controller: controller,
onChanged: (value) {
setState(() {
_sectionFilters[section] = value;
});
},
cursorColor: colorScheme.primary,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
height: 1.1,
),
decoration: InputDecoration(
hintText: 'Search this section',
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.85,
),
),
filled: true,
fillColor: Colors.transparent,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 10,
),
),
),
),
if (hasFilter) ...[
if (onSecondaryAction != null &&
secondaryActionIcon != null)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Tooltip(
message: secondaryActionTooltip ?? '',
child: Material(
color: colorScheme.tertiary.withValues(alpha: 0.10),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onSecondaryAction,
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
secondaryActionIcon,
size: 16,
color: colorScheme.tertiary,
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 4),
child: Material(
color:
(isSavedFilter
? colorScheme.error
: colorScheme.primary)
.withValues(alpha: 0.10),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => _toggleSavedGroupForSection(
context,
contactsProvider,
section,
),
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
isSavedFilter
? Icons.delete_outline_rounded
: Icons.bookmark_add_outlined,
size: 16,
color: isSavedFilter
? colorScheme.error
: colorScheme.primary,
),
),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 6),
child: Material(
color: colorScheme.primary.withValues(alpha: 0.10),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () {
controller.clear();
setState(() {
_sectionFilters[section] = '';
});
},
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
Icons.close_rounded,
size: 14,
color: colorScheme.primary,
),
),
),
),
),
] else
Row(
children: [
if (onSecondaryAction != null &&
secondaryActionIcon != null)
Padding(
padding: const EdgeInsets.only(right: 6),
child: Tooltip(
message: secondaryActionTooltip ?? '',
child: Material(
color: colorScheme.tertiary.withValues(
alpha: 0.10,
),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onSecondaryAction,
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
secondaryActionIcon,
size: 16,
color: colorScheme.tertiary,
),
),
),
),
),
),
const SizedBox(width: 12),
],
),
],
),
),
),
),
),
);
}
Widget _buildSortMenu(BuildContext context, ContactSection section) {
final l10n = AppLocalizations.of(context)!;
final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen;
final colorScheme = Theme.of(context).colorScheme;
return PopupMenuButton<ContactSortMode>(
tooltip: 'Sort',
initialValue: selectedMode,
onSelected: (sortMode) {
setState(() {
_sortModes[section] = sortMode;
});
},
itemBuilder: (context) => [
PopupMenuItem<ContactSortMode>(
value: ContactSortMode.lastSeen,
child: Row(
children: [
Icon(
Icons.schedule,
size: 18,
color: selectedMode == ContactSortMode.lastSeen
? colorScheme.primary
: null,
),
SizedBox(width: 8),
Text(l10n.lastSeen),
],
),
),
PopupMenuItem<ContactSortMode>(
value: ContactSortMode.distance,
child: Row(
children: [
Icon(
Icons.near_me,
size: 18,
color: selectedMode == ContactSortMode.distance
? colorScheme.primary
: null,
),
SizedBox(width: 8),
Text(l10n.distance),
],
),
),
],
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.more_horiz,
size: 18,
color: colorScheme.onSurfaceVariant,
),
),
);
}
}
enum ContactSortMode { lastSeen, distance }
enum ContactSection { teamMembers, repeaters, sensors, rooms, channels }
class _RenderedSavedGroup {
final SavedContactGroup group;
final List<Contact> contacts;
const _RenderedSavedGroup({required this.group, required this.contacts});
}
class _SectionHeader extends StatelessWidget {
final String title;
final int count;
final IconData icon;
final Widget? trailing;
const _SectionHeader({
required this.title,
required this.count,
required this.icon,
this.trailing,
});
@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,
),
),
if (trailing != null) ...[const Spacer(), trailing!],
],
),
);
}
}
class _InferredContactGroupCard extends StatelessWidget {
final String label;
final List<Contact> contacts;
final bool compactContacts;
final Position? currentPosition;
final double Function(double, double, double, double) calculateDistance;
final String Function(double) formatDistance;
final VoidCallback? onNavigateToMap;
final VoidCallback? onNavigateToMessages;
final VoidCallback? onDelete;
const _InferredContactGroupCard({
required this.label,
required this.contacts,
this.compactContacts = false,
required this.currentPosition,
required this.calculateDistance,
required this.formatDistance,
required this.onNavigateToMap,
required this.onNavigateToMessages,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
childrenPadding: const EdgeInsets.fromLTRB(10, 0, 10, 6),
minTileHeight: 44,
initiallyExpanded: false,
leading: Icon(
Icons.folder_copy_outlined,
size: 16,
color: colorScheme.primary,
),
title: Row(
children: [
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
fontSize: 13,
),
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
contacts.length.toString(),
style: Theme.of(context).textTheme.labelSmall,
),
),
if (onDelete != null) ...[
const SizedBox(width: 2),
IconButton(
tooltip: 'Delete group',
onPressed: onDelete,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 32,
height: 32,
),
padding: EdgeInsets.zero,
icon: Icon(
Icons.delete_outline_rounded,
size: 16,
color: colorScheme.error,
),
),
],
],
),
children: [
...contacts.map(
(contact) => ContactTile(
contact: contact,
compact: compactContacts,
currentPosition: currentPosition,
calculateDistance: calculateDistance,
formatDistance: formatDistance,
onNavigateToMap: onNavigateToMap,
onNavigateToMessages: onNavigateToMessages,
),
),
],
),
),
);
}
}
class _ChannelActivityCard extends StatelessWidget {
final Contact channel;
final MessagesProvider messagesProvider;
final ContactsProvider contactsProvider;
final VoidCallback? onTap;
const _ChannelActivityCard({
required this.channel,
required this.messagesProvider,
required this.contactsProvider,
this.onTap,
});
String _formatRelativeTime(BuildContext context, DateTime when) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
);
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
final lastActivityAt = messagesProvider.getLastActivityForDestination(
channel,
);
final activityLabel = lastActivityAt == null
? null
: _formatRelativeTime(context, lastActivityAt);
final participantNames = <String>[];
for (final message in channelMessages) {
final senderName = message.senderName?.trim();
if (senderName == null || senderName.isEmpty) continue;
if (!participantNames.contains(senderName)) {
participantNames.add(senderName);
}
}
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.surfaceContainerLow,
colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
],
),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.045),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ContactAvatar(contact: channel, radius: 24),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
channel.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: titleStyle,
),
),
const SizedBox(width: 8),
if (activityLabel != null)
Text(
activityLabel,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8),
if (participantNames.isNotEmpty)
_ExpandableParticipantStack(
names: participantNames,
contactForName: _findParticipantContact,
)
else
Text(
'No recent chatters',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_MetricChip(
icon: Icons.forum_outlined,
label: '${channelMessages.length}',
helper: 'messages',
),
_MetricChip(
icon: Icons.group_outlined,
label: '${participantNames.length}',
helper: 'active',
),
],
),
],
),
),
],
),
),
),
),
);
}
Contact? _findParticipantContact(String name) {
for (final contact in contactsProvider.contacts) {
if (!contact.isChannel && contact.advName == name) {
return contact;
}
}
return null;
}
}
class _ExpandableParticipantStack extends StatefulWidget {
final List<String> names;
final Contact? Function(String name) contactForName;
const _ExpandableParticipantStack({
required this.names,
required this.contactForName,
});
@override
State<_ExpandableParticipantStack> createState() =>
_ExpandableParticipantStackState();
}
class _ExpandableParticipantStackState
extends State<_ExpandableParticipantStack> {
bool _expanded = false;
static const int _collapsedVisibleCount = 4;
@override
Widget build(BuildContext context) {
final hasOverflow = widget.names.length > _collapsedVisibleCount;
final visibleNames = _expanded
? widget.names
: widget.names.take(_collapsedVisibleCount).toList();
final overflowCount = _expanded
? 0
: widget.names.length - visibleNames.length;
final spacing = _expanded ? 20.0 : 16.0;
const avatarSize = 24.0;
final itemCount = visibleNames.length + (overflowCount > 0 ? 1 : 0);
final width = itemCount == 0 ? 0.0 : avatarSize + (itemCount - 1) * spacing;
return GestureDetector(
onTap: hasOverflow
? () {
setState(() {
_expanded = !_expanded;
});
}
: null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
width: width,
height: avatarSize,
child: Stack(
clipBehavior: Clip.none,
children: [
for (var i = 0; i < visibleNames.length; i++)
Positioned(
left: i * spacing,
top: 0,
child: _ParticipantAvatar(
name: visibleNames[i],
contact: widget.contactForName(visibleNames[i]),
),
),
if (overflowCount > 0)
Positioned(
left: visibleNames.length * spacing,
top: 0,
child: _OverflowAvatar(count: overflowCount),
),
],
),
),
);
}
}
class _OverflowAvatar extends StatelessWidget {
final int count;
const _OverflowAvatar({required this.count});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.surface, width: 2),
),
alignment: Alignment.center,
child: Text(
'+$count',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
fontSize: 9,
),
),
);
}
}
class _ParticipantAvatar extends StatelessWidget {
final String name;
final Contact? contact;
static const double _size = 24;
const _ParticipantAvatar({required this.name, required this.contact});
@override
Widget build(BuildContext context) {
if (contact != null) {
final surfaceColor = Theme.of(context).colorScheme.surface;
return SizedBox(
width: _size,
height: _size,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: surfaceColor, width: 2),
),
child: Padding(
padding: const EdgeInsets.all(2),
child: ClipOval(child: ContactAvatar(contact: contact!, radius: 8)),
),
),
);
}
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: _size,
height: _size,
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.surface, width: 2),
),
alignment: Alignment.center,
child: Text(
AvatarLabelHelper.buildLabel(name),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onTertiaryContainer,
fontSize: 9,
),
),
);
}
}
class _MetricChip extends StatelessWidget {
final IconData icon;
final String label;
final String helper;
const _MetricChip({
required this.icon,
required this.label,
required this.helper,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 5),
Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(width: 4),
Text(
helper,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}