mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Allow overriding contact name
This commit is contained in:
@@ -2,12 +2,14 @@ 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';
|
||||
@@ -200,7 +202,9 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
return contacts.where((contact) {
|
||||
final name = contact.displayName.toLowerCase();
|
||||
final advertisedName = contact.advName.toLowerCase();
|
||||
return name.contains(query) || advertisedName.contains(query);
|
||||
return name.contains(query) ||
|
||||
advertisedName.contains(query) ||
|
||||
ContactGrouping.contactMatchesInferredGroupLabel(contact, query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -213,7 +217,19 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
final name = contact.displayName.toLowerCase();
|
||||
final advertisedName = contact.advName.toLowerCase();
|
||||
return name.contains(normalizedQuery) ||
|
||||
advertisedName.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(
|
||||
@@ -225,7 +241,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
.savedGroupsForSection(section.name)
|
||||
.map((group) {
|
||||
final matches = contacts
|
||||
.where((contact) => _contactMatchesFilter(contact, group.query))
|
||||
.where((contact) => _contactMatchesSavedGroup(contact, group))
|
||||
.toList();
|
||||
return _RenderedSavedGroup(group: group, contacts: matches);
|
||||
})
|
||||
@@ -238,6 +254,23 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -278,6 +311,48 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -365,6 +440,142 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
);
|
||||
}
|
||||
|
||||
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: const Icon(Icons.message_outlined),
|
||||
title: Text(l10n.messages),
|
||||
onTap: () async {
|
||||
Navigator.pop(sheetContext);
|
||||
await _openMessagesForChannel(context, channel);
|
||||
},
|
||||
),
|
||||
if (channel.displayLocation != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.map_outlined),
|
||||
title: Text(l10n.viewOnMap),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showChannelOnMap(context, channel);
|
||||
},
|
||||
),
|
||||
if (!channel.isPublicChannel)
|
||||
ListTile(
|
||||
leading: const 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)!;
|
||||
@@ -399,6 +610,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
allChatContacts,
|
||||
ContactSection.teamMembers,
|
||||
);
|
||||
final visibleSavedTeamGroups =
|
||||
_showSavedGroupsForSection(ContactSection.teamMembers)
|
||||
? savedTeamGroups
|
||||
: const <_RenderedSavedGroup>[];
|
||||
final repeaters = _filterContactsForSection(
|
||||
allRepeaters,
|
||||
ContactSection.repeaters,
|
||||
@@ -408,6 +623,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
allRepeaters,
|
||||
ContactSection.repeaters,
|
||||
);
|
||||
final visibleSavedRepeaterGroups =
|
||||
_showSavedGroupsForSection(ContactSection.repeaters)
|
||||
? savedRepeaterGroups
|
||||
: const <_RenderedSavedGroup>[];
|
||||
final rooms = _filterContactsForSection(
|
||||
allRooms,
|
||||
ContactSection.rooms,
|
||||
@@ -417,6 +636,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
allRooms,
|
||||
ContactSection.rooms,
|
||||
);
|
||||
final visibleSavedRoomGroups =
|
||||
_showSavedGroupsForSection(ContactSection.rooms)
|
||||
? savedRoomGroups
|
||||
: const <_RenderedSavedGroup>[];
|
||||
final filteredChannels = _filterContactsForSection(
|
||||
allChannels,
|
||||
ContactSection.channels,
|
||||
@@ -426,6 +649,29 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
allChannels,
|
||||
ContactSection.channels,
|
||||
);
|
||||
final visibleSavedChannelGroups =
|
||||
_showSavedGroupsForSection(ContactSection.channels)
|
||||
? savedChannelGroups
|
||||
: const <_RenderedSavedGroup>[];
|
||||
final showTeamMembersSection =
|
||||
allChatContacts.isNotEmpty &&
|
||||
(!_sectionHasActiveFilter(ContactSection.teamMembers) ||
|
||||
chatContacts.isNotEmpty ||
|
||||
visibleSavedTeamGroups.isNotEmpty);
|
||||
final showRepeatersSection =
|
||||
allRepeaters.isNotEmpty &&
|
||||
(!_sectionHasActiveFilter(ContactSection.repeaters) ||
|
||||
repeaters.isNotEmpty ||
|
||||
visibleSavedRepeaterGroups.isNotEmpty);
|
||||
final showRoomsSection =
|
||||
allRooms.isNotEmpty &&
|
||||
(!_sectionHasActiveFilter(ContactSection.rooms) ||
|
||||
rooms.isNotEmpty ||
|
||||
visibleSavedRoomGroups.isNotEmpty);
|
||||
final showChannelsSection =
|
||||
!_sectionHasActiveFilter(ContactSection.channels) ||
|
||||
filteredChannels.isNotEmpty ||
|
||||
visibleSavedChannelGroups.isNotEmpty;
|
||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||
|
||||
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
|
||||
@@ -470,7 +716,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
padding: const EdgeInsets.all(8),
|
||||
children: [
|
||||
// Team Members (Chat contacts)
|
||||
if (allChatContacts.isNotEmpty) ...[
|
||||
if (showTeamMembersSection) ...[
|
||||
_SectionHeader(
|
||||
title: l10n.teamMembers,
|
||||
count: chatContacts.length,
|
||||
@@ -484,23 +730,32 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
context,
|
||||
ContactSection.teamMembers,
|
||||
contactsProvider,
|
||||
),
|
||||
if (chatContacts.isEmpty)
|
||||
_buildEmptyFilterState(context)
|
||||
else ...[
|
||||
..._buildSavedGroupCards(
|
||||
savedTeamGroups,
|
||||
onSecondaryAction: () => _createAutoGroupsForSection(
|
||||
context,
|
||||
contactsProvider,
|
||||
ContactSection.teamMembers,
|
||||
allChatContacts,
|
||||
emptyMessage: 'No contact auto groups available',
|
||||
successMessage: 'Updated contact auto groups',
|
||||
),
|
||||
..._buildContactSectionItems(
|
||||
_excludeGroupedContacts(chatContacts, savedTeamGroups),
|
||||
secondaryActionIcon: Icons.auto_awesome_outlined,
|
||||
secondaryActionTooltip: 'Auto group',
|
||||
),
|
||||
..._buildSavedGroupCards(
|
||||
visibleSavedTeamGroups,
|
||||
ContactSection.teamMembers,
|
||||
),
|
||||
..._buildContactSectionItems(
|
||||
_excludeGroupedContacts(
|
||||
chatContacts,
|
||||
visibleSavedTeamGroups,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
// Repeaters
|
||||
if (allRepeaters.isNotEmpty) ...[
|
||||
if (showRepeatersSection) ...[
|
||||
_SectionHeader(
|
||||
title: l10n.repeaters,
|
||||
count: repeaters.length,
|
||||
@@ -511,23 +766,34 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
context,
|
||||
ContactSection.repeaters,
|
||||
contactsProvider,
|
||||
),
|
||||
if (repeaters.isEmpty)
|
||||
_buildEmptyFilterState(context)
|
||||
else ...[
|
||||
..._buildSavedGroupCards(
|
||||
savedRepeaterGroups,
|
||||
onSecondaryAction: () => _createAutoGroupsForSection(
|
||||
context,
|
||||
contactsProvider,
|
||||
ContactSection.repeaters,
|
||||
allRepeaters,
|
||||
maxNamedGroups: 2,
|
||||
overflowGroupLabel: 'Others',
|
||||
emptyMessage: 'No repeater auto groups available',
|
||||
successMessage: 'Updated repeater auto groups',
|
||||
),
|
||||
..._buildContactSectionItems(
|
||||
_excludeGroupedContacts(repeaters, savedRepeaterGroups),
|
||||
secondaryActionIcon: Icons.auto_awesome_outlined,
|
||||
secondaryActionTooltip: 'Auto group',
|
||||
),
|
||||
..._buildSavedGroupCards(
|
||||
visibleSavedRepeaterGroups,
|
||||
ContactSection.repeaters,
|
||||
),
|
||||
..._buildContactSectionItems(
|
||||
_excludeGroupedContacts(
|
||||
repeaters,
|
||||
visibleSavedRepeaterGroups,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
// Rooms
|
||||
if (allRooms.isNotEmpty) ...[
|
||||
if (showRoomsSection) ...[
|
||||
_SectionHeader(
|
||||
title: l10n.rooms,
|
||||
count: rooms.length,
|
||||
@@ -539,17 +805,13 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
ContactSection.rooms,
|
||||
contactsProvider,
|
||||
),
|
||||
if (rooms.isEmpty)
|
||||
_buildEmptyFilterState(context)
|
||||
else ...[
|
||||
..._buildSavedGroupCards(
|
||||
savedRoomGroups,
|
||||
ContactSection.rooms,
|
||||
),
|
||||
..._buildContactSectionItems(
|
||||
_excludeGroupedContacts(rooms, savedRoomGroups),
|
||||
),
|
||||
],
|
||||
..._buildSavedGroupCards(
|
||||
visibleSavedRoomGroups,
|
||||
ContactSection.rooms,
|
||||
),
|
||||
..._buildContactSectionItems(
|
||||
_excludeGroupedContacts(rooms, visibleSavedRoomGroups),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
@@ -575,34 +837,34 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
],
|
||||
|
||||
// Channels (visible in both simple and advanced mode)
|
||||
_SectionHeader(
|
||||
title: l10n.channels,
|
||||
count: filteredChannels.length,
|
||||
icon: Icons.broadcast_on_personal,
|
||||
),
|
||||
_buildSectionFilterField(
|
||||
context,
|
||||
ContactSection.channels,
|
||||
contactsProvider,
|
||||
),
|
||||
if (allChannels.isNotEmpty && filteredChannels.isEmpty)
|
||||
_buildEmptyFilterState(context),
|
||||
..._buildSavedGroupCards(
|
||||
savedChannelGroups,
|
||||
ContactSection.channels,
|
||||
),
|
||||
if (filteredChannels.isNotEmpty) ...[
|
||||
..._excludeGroupedContacts(
|
||||
filteredChannels,
|
||||
savedChannelGroups,
|
||||
).map(
|
||||
(channel) => _ChannelActivityCard(
|
||||
channel: channel,
|
||||
messagesProvider: messagesProvider,
|
||||
contactsProvider: contactsProvider,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
),
|
||||
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.isNotEmpty) ...[
|
||||
..._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)
|
||||
@@ -633,30 +895,18 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
}
|
||||
|
||||
List<Widget> _buildContactSectionItems(List<Contact> contacts) {
|
||||
final items = ContactGrouping.buildItemsFromSorted(contacts);
|
||||
|
||||
return items.map((item) {
|
||||
if (item.isGroup) {
|
||||
return _InferredContactGroupCard(
|
||||
label: item.group!.label,
|
||||
contacts: item.group!.contacts,
|
||||
currentPosition: _currentPosition,
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
);
|
||||
}
|
||||
|
||||
return ContactTile(
|
||||
contact: item.contact!,
|
||||
currentPosition: _currentPosition,
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
);
|
||||
}).toList();
|
||||
return contacts
|
||||
.map(
|
||||
(contact) => ContactTile(
|
||||
contact: contact,
|
||||
currentPosition: _currentPosition,
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<Contact> _excludeGroupedContacts(
|
||||
@@ -690,7 +940,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
onDelete: () => context
|
||||
.read<ContactsProvider>()
|
||||
.removeSavedGroupById(group.group.id),
|
||||
kindLabel: 'Saved filter',
|
||||
kindLabel: group.group.isAutoGroup ? 'Auto group' : 'Saved filter',
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
@@ -699,8 +949,11 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
Widget _buildSectionFilterField(
|
||||
BuildContext context,
|
||||
ContactSection section,
|
||||
ContactsProvider contactsProvider,
|
||||
) {
|
||||
ContactsProvider contactsProvider, {
|
||||
VoidCallback? onSecondaryAction,
|
||||
IconData? secondaryActionIcon,
|
||||
String? secondaryActionTooltip,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final controller = _filterControllers[section]!;
|
||||
@@ -784,6 +1037,30 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
),
|
||||
),
|
||||
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(
|
||||
@@ -840,7 +1117,37 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
),
|
||||
),
|
||||
] else
|
||||
const SizedBox(width: 12),
|
||||
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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -850,18 +1157,6 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyFilterState(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'No matches for this filter.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSortMenu(BuildContext context, ContactSection section) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen;
|
||||
@@ -1137,13 +1432,13 @@ class _ChannelActivityCard extends StatelessWidget {
|
||||
final Contact channel;
|
||||
final MessagesProvider messagesProvider;
|
||||
final ContactsProvider contactsProvider;
|
||||
final VoidCallback? onNavigateToMessages;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _ChannelActivityCard({
|
||||
required this.channel,
|
||||
required this.messagesProvider,
|
||||
required this.contactsProvider,
|
||||
required this.onNavigateToMessages,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
String _formatRelativeTime(BuildContext context, DateTime when) {
|
||||
@@ -1207,17 +1502,7 @@ class _ChannelActivityCard extends StatelessWidget {
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: () async {
|
||||
await MessageDestinationPreferences.setDestination(
|
||||
MessageDestinationPreferences.destinationTypeChannel,
|
||||
recipientPublicKey: channel.publicKeyHex,
|
||||
);
|
||||
messagesProvider.navigateToDestination(
|
||||
MessageDestinationPreferences.destinationTypeChannel,
|
||||
recipientPublicKeyHex: channel.publicKeyHex,
|
||||
);
|
||||
onNavigateToMessages?.call();
|
||||
},
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
|
||||
@@ -610,6 +610,68 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmFactoryReset() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Wipe device data'),
|
||||
content: const Text(
|
||||
'This will erase all data on the connected device, including contacts, keys, and saved settings. This cannot be undone.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('Wipe device'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
|
||||
try {
|
||||
await connectionProvider.factoryResetDevice();
|
||||
if (!mounted) return;
|
||||
|
||||
if (connectionProvider.error != null) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(connectionProvider.error!),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Factory reset command sent. The device should reboot and disconnect shortly.',
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to wipe device data: $e'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
||||
@@ -1099,6 +1161,74 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_ConfigSectionCard(
|
||||
title: 'Danger zone',
|
||||
subtitle: 'Destructive device actions.',
|
||||
icon: Icons.warning_amber_rounded,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer.withValues(
|
||||
alpha: 0.55,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: colorScheme.error.withValues(alpha: 0.28),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_forever_rounded,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Wipe data on device',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Erase contacts, keys, and radio settings from the connected MeshCore device and return it to factory defaults.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _confirmFactoryReset,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.error,
|
||||
foregroundColor: colorScheme.onError,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
),
|
||||
icon: const Icon(Icons.delete_forever_rounded),
|
||||
label: const Text('Wipe device data'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -345,7 +345,7 @@ class _SummaryPanel extends StatelessWidget {
|
||||
label: 'RX packets',
|
||||
value: '${snapshot.rxCount}',
|
||||
subtitle: totalRxCount == null
|
||||
? 'Last 60 sec'
|
||||
? _windowSummaryLabel(snapshot.windowDuration)
|
||||
: 'Device total $totalRxCount',
|
||||
),
|
||||
_MetricTile(
|
||||
@@ -504,12 +504,15 @@ class _FilterChip extends StatelessWidget {
|
||||
}
|
||||
|
||||
String _windowLabel(Duration duration) {
|
||||
if (duration.inMinutes >= 60) {
|
||||
return '${duration.inMinutes} min';
|
||||
}
|
||||
return '${duration.inMinutes} min';
|
||||
}
|
||||
|
||||
String _windowSummaryLabel(Duration duration) {
|
||||
final minutes = duration.inMinutes;
|
||||
if (minutes == 1) return 'Last 1 min';
|
||||
return 'Last $minutes min';
|
||||
}
|
||||
|
||||
class _MetricTile extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
@@ -527,7 +530,7 @@ class _MetricTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 160,
|
||||
height: 128,
|
||||
height: 136,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.82),
|
||||
|
||||
@@ -22,6 +22,7 @@ import '../models/message.dart';
|
||||
import '../services/background_location_service.dart';
|
||||
import '../services/location_tracking_service.dart';
|
||||
import '../services/map_marker_service.dart';
|
||||
import '../services/message_destination_preferences.dart';
|
||||
import '../services/trail_color_service.dart';
|
||||
import '../widgets/map_debug_info.dart';
|
||||
import '../widgets/map/compass_widget.dart';
|
||||
@@ -816,6 +817,174 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showSarMarkerActions(
|
||||
SarMarker marker,
|
||||
MessagesProvider messagesProvider,
|
||||
ContactsProvider contactsProvider,
|
||||
) async {
|
||||
final message = messagesProvider.getMessageById(marker.id);
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
final theme = Theme.of(sheetContext);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(marker.emoji, style: const TextStyle(fontSize: 24)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
marker.displayName,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
marker.senderName != null
|
||||
? '${marker.timeAgo} • ${marker.senderName}'
|
||||
: marker.timeAgo,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
if (marker.notes != null && marker.notes!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(marker.notes!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (message != null)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.chat_bubble_outline),
|
||||
title: const Text('Open message'),
|
||||
subtitle: const Text('Jump to the related SAR message'),
|
||||
onTap: () async {
|
||||
Navigator.pop(sheetContext);
|
||||
await _openSarMarkerMessage(
|
||||
message,
|
||||
messagesProvider,
|
||||
contactsProvider,
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
title: Text(
|
||||
'Remove marker',
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
subtitle: Text(
|
||||
message != null
|
||||
? 'This also removes the linked SAR message.'
|
||||
: 'Hide this marker from the map.',
|
||||
),
|
||||
onTap: () async {
|
||||
final confirmed = await _confirmSarMarkerRemoval(
|
||||
hasMessage: message != null,
|
||||
);
|
||||
if (!mounted ||
|
||||
!sheetContext.mounted ||
|
||||
confirmed != true) {
|
||||
return;
|
||||
}
|
||||
Navigator.pop(sheetContext);
|
||||
await messagesProvider.removeSarMarkerPermanently(
|
||||
marker.id,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> _confirmSarMarkerRemoval({required bool hasMessage}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Remove SAR marker'),
|
||||
content: Text(
|
||||
hasMessage
|
||||
? 'This will remove the marker and its linked chat message.'
|
||||
: 'This will hide the marker from the map, even if it is not visible in chat.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: Text(AppLocalizations.of(dialogContext)!.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: Text(AppLocalizations.of(dialogContext)!.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openSarMarkerMessage(
|
||||
Message message,
|
||||
MessagesProvider messagesProvider,
|
||||
ContactsProvider contactsProvider,
|
||||
) async {
|
||||
if (message.isChannelMessage) {
|
||||
final channelContact = contactsProvider.channels.where((contact) {
|
||||
return contact.publicKey.length > 1 &&
|
||||
contact.publicKey[1] == (message.channelIdx ?? 0);
|
||||
}).firstOrNull;
|
||||
|
||||
messagesProvider.navigateToDestination(
|
||||
MessageDestinationPreferences.destinationTypeChannel,
|
||||
recipientPublicKeyHex: channelContact?.publicKeyHex,
|
||||
);
|
||||
} else {
|
||||
Contact? destinationContact;
|
||||
|
||||
if (message.recipientPublicKey != null) {
|
||||
destinationContact = contactsProvider.contacts.where((contact) {
|
||||
return contact.publicKey.length >=
|
||||
message.recipientPublicKey!.length &&
|
||||
contact.publicKey.matches(message.recipientPublicKey!);
|
||||
}).firstOrNull;
|
||||
} else if (message.senderPublicKeyPrefix != null &&
|
||||
message.senderPublicKeyPrefix!.length >= 6) {
|
||||
destinationContact = contactsProvider.findContactByPrefix(
|
||||
message.senderPublicKeyPrefix!,
|
||||
);
|
||||
}
|
||||
|
||||
if (destinationContact != null) {
|
||||
messagesProvider.navigateToDestination(
|
||||
destinationContact.isRoom
|
||||
? MessageDestinationPreferences.destinationTypeRoom
|
||||
: MessageDestinationPreferences.destinationTypeContact,
|
||||
recipientPublicKeyHex: destinationContact.publicKeyHex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
messagesProvider.navigateToMessage(message.id);
|
||||
widget.onNavigateToMessages?.call();
|
||||
}
|
||||
|
||||
/// Show SAR dialog with pre-populated location from map long press
|
||||
void _showSarDialogWithLocation(LatLng location) {
|
||||
// Create a Position object from the LatLng coordinates
|
||||
@@ -1119,9 +1288,26 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
|
||||
return Consumer3<ContactsProvider, MessagesProvider, DrawingProvider>(
|
||||
builder: (context, contactsProvider, messagesProvider, drawingProvider, child) {
|
||||
final contactsWithLocation = contactsProvider.contactsWithLocation;
|
||||
return Consumer4<
|
||||
ContactsProvider,
|
||||
MessagesProvider,
|
||||
DrawingProvider,
|
||||
MapProvider
|
||||
>(
|
||||
builder: (
|
||||
context,
|
||||
contactsProvider,
|
||||
messagesProvider,
|
||||
drawingProvider,
|
||||
mapProvider,
|
||||
child,
|
||||
) {
|
||||
final allContactsWithLocation = contactsProvider.contactsWithLocation;
|
||||
final contactsWithLocation = mapProvider.hideRepeatersOnMap
|
||||
? allContactsWithLocation
|
||||
.where((contact) => !contact.isRepeater)
|
||||
.toList()
|
||||
: allContactsWithLocation;
|
||||
// Filter SAR markers based on visibility toggle
|
||||
final allSarMarkers = messagesProvider.sarMarkers;
|
||||
final sarMarkers = drawingProvider.showSarMarkers
|
||||
@@ -1719,7 +1905,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
onTap: (contact) {
|
||||
_showDetailedCompassWithContact(
|
||||
context,
|
||||
contactsProvider.contactsWithLocation,
|
||||
contactsWithLocation,
|
||||
messagesProvider.sarMarkers,
|
||||
contact,
|
||||
);
|
||||
@@ -1731,9 +1917,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
context: context,
|
||||
mapRotation: _getMapRotation(),
|
||||
onTap: (marker) {
|
||||
// Navigate to the corresponding message in Messages tab
|
||||
messagesProvider.navigateToMessage(marker.id);
|
||||
widget.onNavigateToMessages?.call();
|
||||
_showSarMarkerActions(
|
||||
marker,
|
||||
messagesProvider,
|
||||
contactsProvider,
|
||||
);
|
||||
},
|
||||
),
|
||||
// User location marker with directional pointer
|
||||
@@ -2021,7 +2209,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
child: GestureDetector(
|
||||
onTap: () => _showDetailedCompass(
|
||||
context,
|
||||
contactsProvider.contactsWithLocation,
|
||||
contactsWithLocation,
|
||||
messagesProvider.sarMarkers,
|
||||
),
|
||||
child: CompassWidget(
|
||||
|
||||
@@ -57,6 +57,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
int _messageByteCount = 0;
|
||||
String? _highlightedMessageId;
|
||||
Timer? _highlightTimer; // Timer for clearing message highlight
|
||||
TextEditingValue _lastComposerValue = const TextEditingValue();
|
||||
bool _isMentionPickerOpen = false;
|
||||
bool _suppressMentionTrigger = false;
|
||||
|
||||
// Message destination state
|
||||
String _destinationType =
|
||||
@@ -85,7 +88,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textController.addListener(_updateCharacterCount);
|
||||
_lastComposerValue = _textController.value;
|
||||
_textController.addListener(_handleComposerChanged);
|
||||
// Load saved message destination
|
||||
_loadSavedDestination();
|
||||
_loadVoiceSettings();
|
||||
@@ -177,12 +181,73 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleComposerChanged() {
|
||||
final previousValue = _lastComposerValue;
|
||||
final currentValue = _textController.value;
|
||||
_lastComposerValue = currentValue;
|
||||
|
||||
_updateCharacterCount();
|
||||
|
||||
if (_suppressMentionTrigger || _isMentionPickerOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
final mentionTriggerRange = _getMentionTriggerRange(
|
||||
previousValue: previousValue,
|
||||
currentValue: currentValue,
|
||||
);
|
||||
if (mentionTriggerRange == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(_showMentionSelectorForRange(mentionTriggerRange));
|
||||
}
|
||||
|
||||
void _updateCharacterCount() {
|
||||
setState(() {
|
||||
_messageByteCount = utf8.encode(_textController.text).length;
|
||||
});
|
||||
}
|
||||
|
||||
TextRange? _getMentionTriggerRange({
|
||||
required TextEditingValue previousValue,
|
||||
required TextEditingValue currentValue,
|
||||
}) {
|
||||
if (!previousValue.selection.isValid ||
|
||||
!currentValue.selection.isValid ||
|
||||
!previousValue.selection.isCollapsed ||
|
||||
!currentValue.selection.isCollapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final previousOffset = previousValue.selection.baseOffset;
|
||||
final currentOffset = currentValue.selection.baseOffset;
|
||||
if (previousOffset < 0 || currentOffset < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentValue.text.length != previousValue.text.length + 1 ||
|
||||
currentOffset != previousOffset + 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentValue.text.substring(0, previousOffset) !=
|
||||
previousValue.text.substring(0, previousOffset)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentValue.text.substring(currentOffset) !=
|
||||
previousValue.text.substring(previousOffset)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentValue.text[previousOffset] != '@') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TextRange(start: previousOffset, end: currentOffset);
|
||||
}
|
||||
|
||||
int get _maxMessageBytes =>
|
||||
_destinationType == MessageDestinationPreferences.destinationTypeChannel
|
||||
? _maxChannelMessageBytes
|
||||
@@ -300,6 +365,56 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showMentionSelectorForRange(TextRange triggerRange) async {
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final contacts = contactsProvider.contacts
|
||||
.where((contact) => contact.type == ContactType.chat)
|
||||
.toList();
|
||||
|
||||
if (contacts.isEmpty || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isMentionPickerOpen = true;
|
||||
Contact? selectedContact;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => RecipientSelectorSheet(
|
||||
contacts: contacts,
|
||||
rooms: const [],
|
||||
channels: const [],
|
||||
unreadCount: 0,
|
||||
unreadCountsByPublicKey: {
|
||||
for (final contact in contacts) contact.publicKeyHex: 0,
|
||||
},
|
||||
currentDestinationType: null,
|
||||
currentRecipientPublicKey: null,
|
||||
showAllOption: false,
|
||||
onSelect: (_, recipient) {
|
||||
selectedContact = recipient;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
_isMentionPickerOpen = false;
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedContact != null) {
|
||||
_insertReplyMention(
|
||||
selectedContact!.displayName,
|
||||
replacementRange: triggerRange,
|
||||
);
|
||||
}
|
||||
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
/// Handle recipient selection
|
||||
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
|
||||
setState(() {
|
||||
@@ -338,13 +453,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _insertReplyMention(String displayName) {
|
||||
void _insertReplyMention(String displayName, {TextRange? replacementRange}) {
|
||||
final trimmedName = displayName.trim();
|
||||
if (trimmedName.isEmpty) return;
|
||||
|
||||
final mention = '@[$trimmedName] ';
|
||||
final value = _textController.value;
|
||||
final selection = value.selection;
|
||||
final selection = replacementRange ?? value.selection;
|
||||
final hasSelection =
|
||||
selection.isValid &&
|
||||
selection.start >= 0 &&
|
||||
@@ -355,11 +470,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
final nextText = value.text.replaceRange(start, end, mention);
|
||||
final nextOffset = start + mention.length;
|
||||
|
||||
_suppressMentionTrigger = true;
|
||||
_textController.value = value.copyWith(
|
||||
text: nextText,
|
||||
selection: TextSelection.collapsed(offset: nextOffset),
|
||||
composing: TextRange.empty,
|
||||
);
|
||||
_lastComposerValue = _textController.value;
|
||||
_suppressMentionTrigger = false;
|
||||
_enforceMessageByteLimit();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import '../providers/contacts_provider.dart';
|
||||
import '../providers/messages_provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/drawing_provider.dart';
|
||||
import '../providers/map_provider.dart';
|
||||
import '../services/location_tracking_service.dart';
|
||||
import '../services/locale_preferences.dart';
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
@@ -72,6 +74,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _fastLocationUpdatesEnabled = false;
|
||||
double _fastLocationMovementThresholdMeters = 10.0;
|
||||
int _fastLocationActiveCadenceSeconds = 10;
|
||||
bool _rotateMapWithHeading = false;
|
||||
bool _showMapDebugInfo = false;
|
||||
bool _openMapInFullscreen = false;
|
||||
bool _isDeveloperModeEnabled = false;
|
||||
DateTime? _onlineTraceCacheUpdatedAt;
|
||||
bool _isClearingOnlineTraceCache = false;
|
||||
@@ -93,6 +98,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_loadFastLocationSettings();
|
||||
_loadDeveloperMode();
|
||||
_loadOnlineTraceCacheStatus();
|
||||
_loadMapPreferences();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -177,6 +183,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await prefs.setBool('show_rx_tx_indicators', value);
|
||||
}
|
||||
|
||||
Future<void> _loadMapPreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_rotateMapWithHeading =
|
||||
prefs.getBool('map_rotate_with_heading') ?? false;
|
||||
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false;
|
||||
_openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveMapPreference(String key, bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(key, value);
|
||||
}
|
||||
|
||||
Future<void> _loadVoicePreferences() async {
|
||||
final value = await VoiceBitratePreferences.getBitrate();
|
||||
if (!mounted) return;
|
||||
@@ -1154,6 +1176,240 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
]),
|
||||
|
||||
_buildSectionHeader('Map'),
|
||||
_buildSettingsCard([
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.explore),
|
||||
title: const Text('Rotate map with heading'),
|
||||
subtitle: const Text(
|
||||
'Rotate the map based on your compass or movement heading',
|
||||
),
|
||||
value: _rotateMapWithHeading,
|
||||
onChanged: (value) async {
|
||||
setState(() {
|
||||
_rotateMapWithHeading = value;
|
||||
});
|
||||
await _saveMapPreference('map_rotate_with_heading', value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.bug_report_outlined),
|
||||
title: const Text('Show map debug info'),
|
||||
subtitle: const Text(
|
||||
'Display extra map diagnostics and internal state overlays',
|
||||
),
|
||||
value: _showMapDebugInfo,
|
||||
onChanged: (value) async {
|
||||
setState(() {
|
||||
_showMapDebugInfo = value;
|
||||
});
|
||||
await _saveMapPreference('map_show_debug_info', value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.fullscreen),
|
||||
title: const Text('Open map in fullscreen'),
|
||||
subtitle: const Text(
|
||||
'Start the map tab in fullscreen mode by default',
|
||||
),
|
||||
value: _openMapInFullscreen,
|
||||
onChanged: (value) async {
|
||||
setState(() {
|
||||
_openMapInFullscreen = value;
|
||||
});
|
||||
await _saveMapPreference('map_fullscreen', value);
|
||||
},
|
||||
),
|
||||
Consumer<DrawingProvider>(
|
||||
builder: (context, drawingProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.fmd_good_outlined),
|
||||
title: const Text('Show SAR markers'),
|
||||
subtitle: const Text(
|
||||
'Display SAR markers on the main map',
|
||||
),
|
||||
value: drawingProvider.showSarMarkers,
|
||||
onChanged: (value) {
|
||||
drawingProvider.toggleSarMarkers();
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.timeline),
|
||||
title: const Text('Show all contact trails'),
|
||||
subtitle: const Text(
|
||||
'Display location trails for all contacts that have history',
|
||||
),
|
||||
value: mapProvider.showAllContactTrails,
|
||||
onChanged: (value) async {
|
||||
await mapProvider.toggleAllContactTrails();
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.router_outlined),
|
||||
title: const Text('Hide repeaters on map'),
|
||||
subtitle: const Text(
|
||||
'Hide repeater contacts from the main map view',
|
||||
),
|
||||
value: mapProvider.hideRepeatersOnMap,
|
||||
onChanged: (value) async {
|
||||
await mapProvider.setHideRepeatersOnMap(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
]),
|
||||
_buildSettingsCard([
|
||||
ListTile(
|
||||
leading: const Icon(Icons.layers_outlined),
|
||||
title: const Text('Rendering'),
|
||||
subtitle: const Text(
|
||||
'Control map drawings and overlay layers used by the renderer',
|
||||
),
|
||||
),
|
||||
Consumer<DrawingProvider>(
|
||||
builder: (context, drawingProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.draw_outlined),
|
||||
title: Text(AppLocalizations.of(context)!.showReceivedDrawings),
|
||||
subtitle: const Text(
|
||||
'Render drawings received from other devices on the map',
|
||||
),
|
||||
value: drawingProvider.showReceivedDrawings,
|
||||
onChanged: (value) async {
|
||||
await drawingProvider.toggleReceivedDrawings();
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.grid_on, color: Colors.blue),
|
||||
title: Text(AppLocalizations.of(context)!.cadastralParcels),
|
||||
subtitle: const Text('WMS overlay'),
|
||||
value: mapProvider.showCadastralOverlay,
|
||||
onChanged: (value) async {
|
||||
await mapProvider.toggleCadastralOverlay();
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.route, color: Colors.green),
|
||||
title: Text(AppLocalizations.of(context)!.forestRoads),
|
||||
subtitle: const Text('WMS overlay'),
|
||||
value: mapProvider.showForestRoadsOverlay,
|
||||
onChanged: (value) async {
|
||||
await mapProvider.toggleForestRoadsOverlay();
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.hiking, color: Colors.brown),
|
||||
title: Text(AppLocalizations.of(context)!.hikingTrails),
|
||||
subtitle: const Text('WMS overlay'),
|
||||
value: mapProvider.showHikingTrailsOverlay,
|
||||
onChanged: (value) async {
|
||||
await mapProvider.toggleHikingTrailsOverlay();
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.alt_route, color: Colors.grey),
|
||||
title: Text(AppLocalizations.of(context)!.mainRoads),
|
||||
subtitle: const Text('WMS overlay'),
|
||||
value: mapProvider.showMainRoadsOverlay,
|
||||
onChanged: (value) async {
|
||||
await mapProvider.toggleMainRoadsOverlay();
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.numbers, color: Colors.purple),
|
||||
title: Text(AppLocalizations.of(context)!.houseNumbers),
|
||||
subtitle: const Text('WMS overlay'),
|
||||
value: mapProvider.showHouseNumbersOverlay,
|
||||
onChanged: (value) async {
|
||||
await mapProvider.toggleHouseNumbersOverlay();
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(
|
||||
Icons.warning_amber,
|
||||
color: Colors.orange,
|
||||
),
|
||||
title: Text(AppLocalizations.of(context)!.fireHazardZones),
|
||||
subtitle: const Text('WMS overlay'),
|
||||
value: mapProvider.showFireHazardZonesOverlay,
|
||||
onChanged: (value) async {
|
||||
await mapProvider.toggleFireHazardZonesOverlay();
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(
|
||||
Icons.local_fire_department,
|
||||
color: Colors.red,
|
||||
),
|
||||
title: Text(AppLocalizations.of(context)!.historicalFires),
|
||||
subtitle: const Text('WMS overlay'),
|
||||
value: mapProvider.showHistoricalFiresOverlay,
|
||||
onChanged: (value) async {
|
||||
await mapProvider.toggleHistoricalFiresOverlay();
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.forest, color: Colors.teal),
|
||||
title: Text(AppLocalizations.of(context)!.firebreaks),
|
||||
subtitle: const Text('WMS overlay'),
|
||||
value: mapProvider.showFirebreaksOverlay,
|
||||
onChanged: (value) async {
|
||||
await mapProvider.toggleFirebreaksOverlay();
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.warning, color: Colors.deepOrange),
|
||||
title: Text(AppLocalizations.of(context)!.krasFireZones),
|
||||
subtitle: const Text('WMS overlay'),
|
||||
value: mapProvider.showKrasFireZonesOverlay,
|
||||
onChanged: (value) async {
|
||||
await mapProvider.toggleKrasFireZonesOverlay();
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.place, color: Colors.indigo),
|
||||
title: Text(AppLocalizations.of(context)!.placeNames),
|
||||
subtitle: const Text('WMS overlay'),
|
||||
value: mapProvider.showPlaceNamesOverlay,
|
||||
onChanged: (value) async {
|
||||
await mapProvider.togglePlaceNamesOverlay();
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.border_outer, color: Colors.cyan),
|
||||
title: Text(AppLocalizations.of(context)!.municipalityBorders),
|
||||
subtitle: const Text('WMS overlay'),
|
||||
value: mapProvider.showMunicipalityBordersOverlay,
|
||||
onChanged: (value) async {
|
||||
await mapProvider.toggleMunicipalityBordersOverlay();
|
||||
},
|
||||
),
|
||||
),
|
||||
]),
|
||||
|
||||
_buildSectionHeader('Voice'),
|
||||
Consumer2<AppProvider, ConnectionProvider>(
|
||||
builder: (context, appProvider, connectionProvider, child) =>
|
||||
|
||||
Reference in New Issue
Block a user