feat: Update project version to 19, add repeaters filter and theme support, and enhance contact list display

This commit is contained in:
Janez T
2025-10-16 21:43:19 +02:00
parent 58d8ef0dae
commit e74ebda54d
15 changed files with 312 additions and 95 deletions

View File

@@ -6,11 +6,14 @@ import '../../../models/contact.dart';
/// Contact list section for the compass dialog.
/// Shows all contacts with location sorted by distance with bearing information.
/// Splits contacts by type: Persons/Team, Repeaters, and Rooms.
class CompassContactList extends StatelessWidget {
final List<Contact> contacts;
final Position? position;
final double? heading;
final Contact? selectedContact;
final bool showContacts;
final bool showRepeaters;
final ValueChanged<Contact?> onContactTap;
const CompassContactList({
@@ -19,6 +22,8 @@ class CompassContactList extends StatelessWidget {
required this.position,
this.heading,
required this.selectedContact,
required this.showContacts,
required this.showRepeaters,
required this.onContactTap,
});
@@ -32,9 +37,16 @@ class CompassContactList extends StatelessWidget {
return Text(AppLocalizations.of(context)!.locationUnavailable);
}
// Calculate bearings and distances
final contactsWithBearing = contacts.map((contact) {
if (contact.displayLocation == null) return null;
final l10n = AppLocalizations.of(context)!;
// Split contacts by type
final persons = <Map<String, dynamic>>[];
final repeaters = <Map<String, dynamic>>[];
final rooms = <Map<String, dynamic>>[];
// Calculate bearings and distances for each contact
for (final contact in contacts) {
if (contact.displayLocation == null) continue;
final bearing = _calculateBearing(
position!.latitude,
@@ -50,101 +62,160 @@ class CompassContactList extends StatelessWidget {
contact.displayLocation!.longitude,
);
return {
final item = {
'contact': contact,
'bearing': bearing,
'distance': distance,
};
}).whereType<Map<String, dynamic>>().toList();
// Sort by distance
contactsWithBearing.sort((a, b) =>
(a['distance'] as double).compareTo(b['distance'] as double));
if (contact.isRepeater) {
repeaters.add(item);
} else if (contact.isRoom) {
rooms.add(item);
} else {
persons.add(item);
}
}
// Sort each list by distance
persons.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
repeaters.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
rooms.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8),
child: Text(
l10n.nearbyContacts,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...contactsWithBearing.map((item) {
final contact = item['contact'] as Contact;
final bearing = item['bearing'] as double;
final distance = item['distance'] as double;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: selectedContact == contact
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: selectedContact == contact
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
child: ListTile(
dense: true,
leading: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 24),
)
: Icon(
Icons.person,
color: Theme.of(context).colorScheme.primary,
size: 24,
),
title: Text(contact.displayName),
subtitle: Text(
'${_bearingToCardinal(bearing)}${_formatDistance(distance)}',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${bearing.round()}°',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
),
// Persons/Team section
if (showContacts && persons.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 4),
child: Text(
l10n.teamMembers,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
if (heading != null)
Text(
_formatRelativeBearing(bearing, heading!, context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
),
],
),
onTap: () {
if (selectedContact == contact) {
// Deselect if already selected
onContactTap(null);
} else {
// Select this contact
onContactTap(contact);
}
},
),
);
}),
),
...persons.map((item) => _buildContactTile(
context,
item,
Icons.groups,
Theme.of(context).colorScheme.primary,
)),
],
// Repeaters section
if (showRepeaters && repeaters.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
child: Text(
l10n.repeaters,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...repeaters.map((item) => _buildContactTile(
context,
item,
Icons.router,
Colors.purple,
)),
],
// Rooms section
if (rooms.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
child: Text(
l10n.rooms,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...rooms.map((item) => _buildContactTile(
context,
item,
Icons.meeting_room,
Colors.teal,
)),
],
],
);
}
Widget _buildContactTile(
BuildContext context,
Map<String, dynamic> item,
IconData defaultIcon,
Color iconColor,
) {
final contact = item['contact'] as Contact;
final bearing = item['bearing'] as double;
final distance = item['distance'] as double;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: selectedContact == contact
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: selectedContact == contact
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
child: ListTile(
dense: true,
leading: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 24),
)
: Icon(
defaultIcon,
color: iconColor,
size: 24,
),
title: Text(contact.displayName),
subtitle: Text(
'${_bearingToCardinal(bearing)}${_formatDistance(distance)}',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${bearing.round()}°',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (heading != null)
Text(
_formatRelativeBearing(bearing, heading!, context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
),
],
),
onTap: () {
if (selectedContact == contact) {
// Deselect if already selected
onContactTap(null);
} else {
// Select this contact
onContactTap(contact);
}
},
),
);
}
// Calculate bearing between two points (in degrees)
double _calculateBearing(
double lat1, double lon1, double lat2, double lon2) {

View File

@@ -5,10 +5,12 @@ import '../../../l10n/app_localizations.dart';
/// Allows filtering of contacts and SAR marker types.
class CompassFilters extends StatefulWidget {
final bool showContacts;
final bool showRepeaters;
final bool showFoundPerson;
final bool showFire;
final bool showStagingArea;
final ValueChanged<bool> onShowContactsChanged;
final ValueChanged<bool> onShowRepeatersChanged;
final ValueChanged<bool> onShowFoundPersonChanged;
final ValueChanged<bool> onShowFireChanged;
final ValueChanged<bool> onShowStagingAreaChanged;
@@ -17,10 +19,12 @@ class CompassFilters extends StatefulWidget {
const CompassFilters({
super.key,
required this.showContacts,
required this.showRepeaters,
required this.showFoundPerson,
required this.showFire,
required this.showStagingArea,
required this.onShowContactsChanged,
required this.onShowRepeatersChanged,
required this.onShowFoundPersonChanged,
required this.onShowFireChanged,
required this.onShowStagingAreaChanged,
@@ -62,6 +66,18 @@ class _CompassFiltersState extends State<CompassFilters> {
},
),
const SizedBox(height: 4),
// Repeaters filter
_CompactFilterItem(
icon: Icons.router,
color: Colors.purple,
label: l10n.repeatersFilter,
value: widget.showRepeaters,
onChanged: (value) {
widget.onShowRepeatersChanged(value);
setDialogState(() {});
},
),
const SizedBox(height: 4),
const Divider(height: 8),
const SizedBox(height: 4),
// SAR Markers section

View File

@@ -65,6 +65,7 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
// Visibility toggles
bool _showContacts = true;
bool _showRepeaters = false; // Hide repeaters by default
bool _showFoundPerson = true;
bool _showFire = true;
bool _showStagingArea = true;
@@ -308,6 +309,7 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
),
CompassFilters(
showContacts: _showContacts,
showRepeaters: _showRepeaters,
showFoundPerson: _showFoundPerson,
showFire: _showFire,
showStagingArea: _showStagingArea,
@@ -316,6 +318,11 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
_showContacts = value;
});
},
onShowRepeatersChanged: (value) {
setState(() {
_showRepeaters = value;
});
},
onShowFoundPersonChanged: (value) {
setState(() {
_showFoundPerson = value;
@@ -334,6 +341,7 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
onShowAll: () {
setState(() {
_showContacts = true;
_showRepeaters = true;
_showFoundPerson = true;
_showFire = true;
_showStagingArea = true;
@@ -363,7 +371,10 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
? [_selectedContact!]
: (_selectedSarMarker != null
? []
: (_showContacts ? widget.contacts : [])),
: widget.contacts.where((c) =>
(_showContacts && !c.isRepeater && !c.isRoom) ||
(_showRepeaters && c.isRepeater)
).toList()),
sarMarkers: _selectedSarMarker != null
? [_selectedSarMarker!]
: (_selectedContact != null
@@ -381,12 +392,14 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
_buildSelectedItemDetail(context, heading, position),
const SizedBox(height: 12),
// Contacts list
if (_showContacts && widget.contacts.isNotEmpty)
if (widget.contacts.isNotEmpty)
CompassContactList(
contacts: widget.contacts,
position: position,
heading: heading,
selectedContact: _selectedContact,
showContacts: _showContacts,
showRepeaters: _showRepeaters,
onContactTap: (contact) {
setState(() {
_selectedContact = contact;