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:
@@ -5,22 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00029">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00026">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.339599">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.640279">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="116.294416">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="104.207919">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="441.763052">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="184.441335">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ class SavedContactGroup {
|
|||||||
final String label;
|
final String label;
|
||||||
final String query;
|
final String query;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
final List<String>? matchPrefixes;
|
||||||
|
final bool isAutoGroup;
|
||||||
|
|
||||||
const SavedContactGroup({
|
const SavedContactGroup({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -11,6 +13,8 @@ class SavedContactGroup {
|
|||||||
required this.label,
|
required this.label,
|
||||||
required this.query,
|
required this.query,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
this.matchPrefixes,
|
||||||
|
this.isAutoGroup = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
SavedContactGroup copyWith({
|
SavedContactGroup copyWith({
|
||||||
@@ -19,6 +23,8 @@ class SavedContactGroup {
|
|||||||
String? label,
|
String? label,
|
||||||
String? query,
|
String? query,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
|
List<String>? matchPrefixes,
|
||||||
|
bool? isAutoGroup,
|
||||||
}) {
|
}) {
|
||||||
return SavedContactGroup(
|
return SavedContactGroup(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -26,6 +32,8 @@ class SavedContactGroup {
|
|||||||
label: label ?? this.label,
|
label: label ?? this.label,
|
||||||
query: query ?? this.query,
|
query: query ?? this.query,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
matchPrefixes: matchPrefixes ?? this.matchPrefixes,
|
||||||
|
isAutoGroup: isAutoGroup ?? this.isAutoGroup,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1627,6 +1627,22 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> factoryResetDevice() async {
|
||||||
|
if (!_activeService.isConnected) {
|
||||||
|
_error = 'Not connected to device';
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_error = null;
|
||||||
|
await _activeService.factoryReset();
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Failed to wipe device data: $e';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Set advertised name
|
/// Set advertised name
|
||||||
Future<void> setAdvertName(String name) async {
|
Future<void> setAdvertName(String name) async {
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class _RetainedRoute {
|
|||||||
/// Contacts Provider - manages contact list and telemetry
|
/// Contacts Provider - manages contact list and telemetry
|
||||||
class ContactsProvider with ChangeNotifier {
|
class ContactsProvider with ChangeNotifier {
|
||||||
static const double _firstHopFallbackOffsetMeters = 100.0;
|
static const double _firstHopFallbackOffsetMeters = 100.0;
|
||||||
|
static const String autoGroupIdPrefix = 'auto_group_';
|
||||||
final Map<String, Contact> _contacts = {};
|
final Map<String, Contact> _contacts = {};
|
||||||
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
|
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
|
||||||
final Map<String, PendingAdvert> _pendingAdverts = {};
|
final Map<String, PendingAdvert> _pendingAdverts = {};
|
||||||
@@ -248,6 +249,8 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
String sectionKey,
|
String sectionKey,
|
||||||
String query, {
|
String query, {
|
||||||
String? label,
|
String? label,
|
||||||
|
List<String>? matchPrefixes,
|
||||||
|
bool isAutoGroup = false,
|
||||||
}) async {
|
}) async {
|
||||||
final normalizedQuery = _normalizeGroupQuery(query);
|
final normalizedQuery = _normalizeGroupQuery(query);
|
||||||
if (normalizedQuery.isEmpty ||
|
if (normalizedQuery.isEmpty ||
|
||||||
@@ -262,6 +265,8 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
label: (label ?? query).trim(),
|
label: (label ?? query).trim(),
|
||||||
query: query.trim(),
|
query: query.trim(),
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
|
matchPrefixes: matchPrefixes,
|
||||||
|
isAutoGroup: isAutoGroup,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -299,6 +304,18 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> replaceAutoGroupsForSection(
|
||||||
|
String sectionKey,
|
||||||
|
List<SavedContactGroup> groups,
|
||||||
|
) async {
|
||||||
|
_savedContactGroups.removeWhere(
|
||||||
|
(group) => group.sectionKey == sectionKey && group.isAutoGroup,
|
||||||
|
);
|
||||||
|
_savedContactGroups.addAll(groups);
|
||||||
|
await _persistSavedGroups();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _persistSavedGroups() async {
|
Future<void> _persistSavedGroups() async {
|
||||||
try {
|
try {
|
||||||
await _storageService.saveContactGroups(_savedContactGroups);
|
await _storageService.saveContactGroups(_savedContactGroups);
|
||||||
@@ -486,6 +503,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
if (existingContact == null) {
|
if (existingContact == null) {
|
||||||
var newContact = incomingContact.copyWith(
|
var newContact = incomingContact.copyWith(
|
||||||
isNew: true,
|
isNew: true,
|
||||||
|
nameOverride: existingContact?.nameOverride,
|
||||||
telemetry: mergedTelemetry,
|
telemetry: mergedTelemetry,
|
||||||
outPathLen:
|
outPathLen:
|
||||||
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
|
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
|
||||||
@@ -514,6 +532,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
var updatedContact = incomingContact.copyWith(
|
var updatedContact = incomingContact.copyWith(
|
||||||
isNew: false,
|
isNew: false,
|
||||||
|
nameOverride: existingContact.nameOverride,
|
||||||
advertHistory: existingContact.advertHistory,
|
advertHistory: existingContact.advertHistory,
|
||||||
telemetry: mergedTelemetry,
|
telemetry: mergedTelemetry,
|
||||||
outPathLen:
|
outPathLen:
|
||||||
@@ -1023,6 +1042,26 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setContactNameOverride(String publicKeyHex, String? overrideName) {
|
||||||
|
final contact = _contacts[publicKeyHex];
|
||||||
|
if (contact == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedOverride = overrideName?.trim();
|
||||||
|
final nextOverride =
|
||||||
|
(normalizedOverride == null || normalizedOverride.isEmpty)
|
||||||
|
? null
|
||||||
|
: normalizedOverride;
|
||||||
|
if (contact.nameOverride == nextOverride) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_contacts[publicKeyHex] = contact.copyWith(nameOverride: nextOverride);
|
||||||
|
_persistContacts();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
|
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
|
||||||
/// Excludes self key and existing contacts.
|
/// Excludes self key and existing contacts.
|
||||||
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ enum DrawingMode { none, line, rectangle, measure }
|
|||||||
/// Provider for managing map drawings
|
/// Provider for managing map drawings
|
||||||
class DrawingProvider with ChangeNotifier {
|
class DrawingProvider with ChangeNotifier {
|
||||||
static const String _storageKey = 'map_drawings';
|
static const String _storageKey = 'map_drawings';
|
||||||
|
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
|
||||||
|
static const String _showSarMarkersKey = 'map_show_sar_markers';
|
||||||
|
|
||||||
// Drawing state
|
// Drawing state
|
||||||
DrawingMode _drawingMode = DrawingMode.none;
|
DrawingMode _drawingMode = DrawingMode.none;
|
||||||
@@ -57,6 +59,7 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Initialize and load saved drawings
|
/// Initialize and load saved drawings
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
|
await _loadPreferences();
|
||||||
await _loadDrawings();
|
await _loadDrawings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,15 +80,30 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle visibility of received drawings
|
/// Toggle visibility of received drawings
|
||||||
void toggleReceivedDrawings() {
|
Future<void> toggleReceivedDrawings() async {
|
||||||
_showReceivedDrawings = !_showReceivedDrawings;
|
_showReceivedDrawings = !_showReceivedDrawings;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await _savePreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle visibility of SAR markers
|
/// Toggle visibility of SAR markers
|
||||||
void toggleSarMarkers() {
|
Future<void> toggleSarMarkers() async {
|
||||||
_showSarMarkers = !_showSarMarkers;
|
_showSarMarkers = !_showSarMarkers;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await _savePreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_showReceivedDrawings = prefs.getBool(_showReceivedDrawingsKey) ?? true;
|
||||||
|
_showSarMarkers = prefs.getBool(_showSarMarkersKey) ?? true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _savePreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_showReceivedDrawingsKey, _showReceivedDrawings);
|
||||||
|
await prefs.setBool(_showSarMarkersKey, _showSarMarkers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start drawing a line
|
/// Start drawing a line
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -6,6 +8,10 @@ import '../models/location_trail.dart';
|
|||||||
import '../models/map_drawing.dart';
|
import '../models/map_drawing.dart';
|
||||||
|
|
||||||
class MapProvider with ChangeNotifier {
|
class MapProvider with ChangeNotifier {
|
||||||
|
MapProvider() {
|
||||||
|
unawaited(_loadInitialState());
|
||||||
|
}
|
||||||
|
|
||||||
LatLng? _targetLocation;
|
LatLng? _targetLocation;
|
||||||
double? _targetZoom;
|
double? _targetZoom;
|
||||||
bool _shouldAnimate = false;
|
bool _shouldAnimate = false;
|
||||||
@@ -33,6 +39,7 @@ class MapProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Contact trail toggles
|
// Contact trail toggles
|
||||||
bool _showAllContactTrails = true; // Default to showing all contact trails
|
bool _showAllContactTrails = true; // Default to showing all contact trails
|
||||||
|
bool _hideRepeatersOnMap = false;
|
||||||
|
|
||||||
// Imported trail (from GPX)
|
// Imported trail (from GPX)
|
||||||
LocationTrail? _importedTrail;
|
LocationTrail? _importedTrail;
|
||||||
@@ -67,6 +74,7 @@ class MapProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Contact trail getters
|
// Contact trail getters
|
||||||
bool get showAllContactTrails => _showAllContactTrails;
|
bool get showAllContactTrails => _showAllContactTrails;
|
||||||
|
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
|
||||||
|
|
||||||
// Imported trail getters
|
// Imported trail getters
|
||||||
LocationTrail? get importedTrail => _importedTrail;
|
LocationTrail? get importedTrail => _importedTrail;
|
||||||
@@ -350,6 +358,11 @@ class MapProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadInitialState() async {
|
||||||
|
await Future.wait([loadOverlayState(), loadTrailSettings()]);
|
||||||
|
await loadRepeaterVisibilitySettings();
|
||||||
|
}
|
||||||
|
|
||||||
/// Save overlay state to SharedPreferences
|
/// Save overlay state to SharedPreferences
|
||||||
Future<void> _saveOverlayState() async {
|
Future<void> _saveOverlayState() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -386,6 +399,20 @@ class MapProvider with ChangeNotifier {
|
|||||||
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
|
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setHideRepeatersOnMap(bool hide) async {
|
||||||
|
if (_hideRepeatersOnMap == hide) return;
|
||||||
|
_hideRepeatersOnMap = hide;
|
||||||
|
notifyListeners();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('map_hide_repeaters', _hideRepeatersOnMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadRepeaterVisibilitySettings() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_hideRepeatersOnMap = prefs.getBool('map_hide_repeaters') ?? false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Set imported trail (from GPX import)
|
/// Set imported trail (from GPX import)
|
||||||
void setImportedTrail(LocationTrail trail) {
|
void setImportedTrail(LocationTrail trail) {
|
||||||
_importedTrail = trail;
|
_importedTrail = trail;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import 'helpers/message_retry_manager.dart';
|
|||||||
class MessagesProvider with ChangeNotifier {
|
class MessagesProvider with ChangeNotifier {
|
||||||
final List<Message> _messages = [];
|
final List<Message> _messages = [];
|
||||||
final Map<String, SarMarker> _sarMarkers = {};
|
final Map<String, SarMarker> _sarMarkers = {};
|
||||||
|
final Set<String> _removedSarMarkerIds = <String>{};
|
||||||
final MessageStorageService _storageService = MessageStorageService();
|
final MessageStorageService _storageService = MessageStorageService();
|
||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
@@ -125,6 +126,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messages.where((m) => m.isSystemMessage).toList();
|
_messages.where((m) => m.isSystemMessage).toList();
|
||||||
|
|
||||||
List<SarMarker> get sarMarkers => _sarMarkers.values.toList();
|
List<SarMarker> get sarMarkers => _sarMarkers.values.toList();
|
||||||
|
Set<String> get removedSarMarkerIds => Set.unmodifiable(_removedSarMarkerIds);
|
||||||
|
|
||||||
List<SarMarker> get foundPersonMarkers =>
|
List<SarMarker> get foundPersonMarkers =>
|
||||||
sarMarkers.where((m) => m.type == SarMarkerType.foundPerson).toList();
|
sarMarkers.where((m) => m.type == SarMarkerType.foundPerson).toList();
|
||||||
@@ -298,6 +300,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
.loadMessageTransferDetails();
|
.loadMessageTransferDetails();
|
||||||
final storedRouteMetadata = await _storageService
|
final storedRouteMetadata = await _storageService
|
||||||
.loadMessageRouteMetadata();
|
.loadMessageRouteMetadata();
|
||||||
|
final storedRemovedSarMarkerIds = await _storageService
|
||||||
|
.loadRemovedSarMarkerIds();
|
||||||
_messageContactLocations
|
_messageContactLocations
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedContactLocations);
|
..addAll(storedContactLocations);
|
||||||
@@ -310,6 +314,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messageRouteMetadata
|
_messageRouteMetadata
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedRouteMetadata);
|
..addAll(storedRouteMetadata);
|
||||||
|
_removedSarMarkerIds
|
||||||
|
..clear()
|
||||||
|
..addAll(storedRemovedSarMarkerIds);
|
||||||
|
|
||||||
// Add stored messages with enhancement to ensure SAR detection
|
// Add stored messages with enhancement to ensure SAR detection
|
||||||
for (final message in storedMessages) {
|
for (final message in storedMessages) {
|
||||||
@@ -357,7 +364,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// Extract SAR markers
|
// Extract SAR markers
|
||||||
if (enhancedMessage.isSarMarker) {
|
if (enhancedMessage.isSarMarker) {
|
||||||
final marker = enhancedMessage.toSarMarker();
|
final marker = enhancedMessage.toSarMarker();
|
||||||
if (marker != null) {
|
if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
|
||||||
_sarMarkers[marker.id] = marker;
|
_sarMarkers[marker.id] = marker;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -537,21 +544,15 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// - Mesh network retransmissions
|
// - Mesh network retransmissions
|
||||||
// - Multiple paths in the network
|
// - Multiple paths in the network
|
||||||
// - Syncing messages from device queue
|
// - Syncing messages from device queue
|
||||||
if (_isDuplicate(finalMessage)) {
|
final duplicateIndex = _findDuplicateMessageIndex(finalMessage);
|
||||||
|
if (duplicateIndex != -1) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}',
|
'⚠️ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}',
|
||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
|
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
|
||||||
);
|
);
|
||||||
final existingIndex = _messages.indexWhere(
|
final existingId = _messages[duplicateIndex].id;
|
||||||
(existing) =>
|
|
||||||
existing.messageType == finalMessage.messageType &&
|
|
||||||
existing.senderTimestamp == finalMessage.senderTimestamp &&
|
|
||||||
existing.text == finalMessage.text,
|
|
||||||
);
|
|
||||||
if (existingIndex != -1) {
|
|
||||||
final existingId = _messages[existingIndex].id;
|
|
||||||
if (contactLocationSnapshot != null) {
|
if (contactLocationSnapshot != null) {
|
||||||
_messageContactLocations[existingId] = contactLocationSnapshot;
|
_messageContactLocations[existingId] = contactLocationSnapshot;
|
||||||
}
|
}
|
||||||
@@ -559,7 +560,6 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
|
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
|
||||||
}
|
}
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
}
|
|
||||||
return; // Skip duplicate
|
return; // Skip duplicate
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +574,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// If it's a SAR marker message, extract and store the marker
|
// If it's a SAR marker message, extract and store the marker
|
||||||
if (finalMessage.isSarMarker) {
|
if (finalMessage.isSarMarker) {
|
||||||
final marker = finalMessage.toSarMarker();
|
final marker = finalMessage.toSarMarker();
|
||||||
if (marker != null) {
|
if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
|
||||||
_sarMarkers[marker.id] = marker;
|
_sarMarkers[marker.id] = marker;
|
||||||
|
|
||||||
// Trigger urgent notification for received SAR messages (not sent by user)
|
// Trigger urgent notification for received SAR messages (not sent by user)
|
||||||
@@ -598,50 +598,48 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
/// Messages are considered duplicates if they have:
|
/// Messages are considered duplicates if they have:
|
||||||
/// 1. Same sender public key prefix (for contact messages)
|
/// 1. Same sender public key prefix (for contact messages)
|
||||||
/// 2. Same channel index (for channel messages)
|
/// 2. Same channel index (for channel messages)
|
||||||
/// 3. Same sender timestamp
|
/// 3. Same text content
|
||||||
/// 4. Same text content
|
|
||||||
///
|
///
|
||||||
/// Note: Sent messages (isSentMessage=true) are NEVER duplicates
|
/// Note: Sent messages (isSentMessage=true) are NEVER duplicates
|
||||||
/// because they can be retried with different message IDs
|
/// because they can be retried with different message IDs
|
||||||
bool _isDuplicate(Message message) {
|
int _findDuplicateMessageIndex(Message message) {
|
||||||
// Sent messages (our own messages) should never be considered duplicates
|
// Sent messages (our own messages) should never be considered duplicates
|
||||||
// They can be retried multiple times with different IDs
|
// They can be retried multiple times with different IDs
|
||||||
if (message.isSentMessage) {
|
if (message.isSentMessage) {
|
||||||
return false;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _messages.any((existing) {
|
for (int index = 0; index < _messages.length; index++) {
|
||||||
// Check message type matches
|
final existing = _messages[index];
|
||||||
|
if (!_matchesDuplicateScope(existing, message) ||
|
||||||
|
existing.text != message.text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchesDuplicateScope(Message existing, Message message) {
|
||||||
if (existing.messageType != message.messageType) {
|
if (existing.messageType != message.messageType) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check sender matches
|
|
||||||
if (message.isContactMessage) {
|
if (message.isContactMessage) {
|
||||||
// For contact messages, compare sender public key prefix
|
return existing.senderKeyShort == message.senderKeyShort;
|
||||||
if (existing.senderKeyShort != message.senderKeyShort) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
} else if (message.isChannelMessage) {
|
|
||||||
// For channel messages, compare channel index
|
if (message.isChannelMessage) {
|
||||||
if (existing.channelIdx != message.channelIdx) {
|
if (existing.channelIdx != message.channelIdx) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
final existingSender = existing.senderKeyShort ?? existing.senderName;
|
||||||
|
final incomingSender = message.senderKeyShort ?? message.senderName;
|
||||||
|
return existingSender == incomingSender;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check timestamp matches (sender timestamp is the unique identifier from the sender)
|
|
||||||
if (existing.senderTimestamp != message.senderTimestamp) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check text content matches
|
|
||||||
if (existing.text != message.text) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// All criteria match - this is a duplicate
|
|
||||||
return true;
|
return true;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add multiple messages
|
/// Add multiple messages
|
||||||
@@ -654,7 +652,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final enhancedMessage = SarMessageParser.enhanceMessage(message);
|
final enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||||
|
|
||||||
// Check for duplicates
|
// Check for duplicates
|
||||||
if (_isDuplicate(enhancedMessage)) {
|
if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
|
||||||
duplicateCount++;
|
duplicateCount++;
|
||||||
continue; // Skip duplicate
|
continue; // Skip duplicate
|
||||||
}
|
}
|
||||||
@@ -664,7 +662,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
if (enhancedMessage.isSarMarker) {
|
if (enhancedMessage.isSarMarker) {
|
||||||
final marker = enhancedMessage.toSarMarker();
|
final marker = enhancedMessage.toSarMarker();
|
||||||
if (marker != null) {
|
if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
|
||||||
_sarMarkers[marker.id] = marker;
|
_sarMarkers[marker.id] = marker;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -886,17 +884,35 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
return _sarMarkers[id];
|
return _sarMarkers[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Message? getMessageById(String id) {
|
||||||
|
final index = _messages.indexWhere((message) => message.id == id);
|
||||||
|
if (index == -1) return null;
|
||||||
|
return _messages[index];
|
||||||
|
}
|
||||||
|
|
||||||
/// Get recent SAR markers (within last hour)
|
/// Get recent SAR markers (within last hour)
|
||||||
List<SarMarker> getRecentSarMarkers() {
|
List<SarMarker> getRecentSarMarkers() {
|
||||||
return sarMarkers.where((m) => m.isRecent).toList();
|
return sarMarkers.where((m) => m.isRecent).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a SAR marker
|
/// Remove a SAR marker
|
||||||
void removeSarMarker(String id) {
|
Future<void> removeSarMarker(String id) async {
|
||||||
_sarMarkers.remove(id);
|
_sarMarkers.remove(id);
|
||||||
|
_removedSarMarkerIds.add(id);
|
||||||
|
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> removeSarMarkerPermanently(String id) async {
|
||||||
|
final hasBackingMessage = _messages.any((message) => message.id == id);
|
||||||
|
if (hasBackingMessage) {
|
||||||
|
deleteMessage(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeSarMarker(id);
|
||||||
|
}
|
||||||
|
|
||||||
/// Mark all messages as read
|
/// Mark all messages as read
|
||||||
void markAllAsRead() {
|
void markAllAsRead() {
|
||||||
bool hasChanges = false;
|
bool hasChanges = false;
|
||||||
@@ -984,6 +1000,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final marker = message.toSarMarker();
|
final marker = message.toSarMarker();
|
||||||
if (marker != null) {
|
if (marker != null) {
|
||||||
_sarMarkers.remove(marker.id);
|
_sarMarkers.remove(marker.id);
|
||||||
|
_removedSarMarkerIds.add(marker.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,6 +1023,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
|
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1030,17 +1048,21 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
void clearMessages() {
|
void clearMessages() {
|
||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
|
_removedSarMarkerIds.clear();
|
||||||
_messageContactLocations.clear();
|
_messageContactLocations.clear();
|
||||||
_messageReceptionDetails.clear();
|
_messageReceptionDetails.clear();
|
||||||
_messageTransferDetails.clear();
|
_messageTransferDetails.clear();
|
||||||
_messageRouteMetadata.clear();
|
_messageRouteMetadata.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
|
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all SAR markers
|
/// Clear all SAR markers
|
||||||
void clearSarMarkers() {
|
Future<void> clearSarMarkers() async {
|
||||||
|
_removedSarMarkerIds.addAll(_sarMarkers.keys);
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
|
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,11 +1070,13 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
void clearAll() {
|
void clearAll() {
|
||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
|
_removedSarMarkerIds.clear();
|
||||||
_messageContactLocations.clear();
|
_messageContactLocations.clear();
|
||||||
_messageReceptionDetails.clear();
|
_messageReceptionDetails.clear();
|
||||||
_messageTransferDetails.clear();
|
_messageTransferDetails.clear();
|
||||||
_messageRouteMetadata.clear();
|
_messageRouteMetadata.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
|
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1213,7 +1237,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicates (shouldn't happen for sent messages, but be safe)
|
// Check for duplicates (shouldn't happen for sent messages, but be safe)
|
||||||
if (_isDuplicate(enhancedMessage)) {
|
if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}',
|
'⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}',
|
||||||
);
|
);
|
||||||
@@ -1238,7 +1262,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// If it's a SAR marker message, extract and store the marker
|
// If it's a SAR marker message, extract and store the marker
|
||||||
if (sendingMessage.isSarMarker) {
|
if (sendingMessage.isSarMarker) {
|
||||||
final marker = sendingMessage.toSarMarker();
|
final marker = sendingMessage.toSarMarker();
|
||||||
if (marker != null) {
|
if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
|
||||||
debugPrint(' ✅ SAR Marker created:');
|
debugPrint(' ✅ SAR Marker created:');
|
||||||
debugPrint(' marker.id: ${marker.id}');
|
debugPrint(' marker.id: ${marker.id}');
|
||||||
debugPrint(' marker.notes: "${marker.notes}"');
|
debugPrint(' marker.notes: "${marker.notes}"');
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import 'dart:math';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/contact_group.dart';
|
import '../models/contact_group.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../providers/map_provider.dart';
|
||||||
import '../providers/messages_provider.dart';
|
import '../providers/messages_provider.dart';
|
||||||
import '../services/message_destination_preferences.dart';
|
import '../services/message_destination_preferences.dart';
|
||||||
import '../utils/contact_grouping.dart';
|
import '../utils/contact_grouping.dart';
|
||||||
@@ -200,7 +202,9 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
return contacts.where((contact) {
|
return contacts.where((contact) {
|
||||||
final name = contact.displayName.toLowerCase();
|
final name = contact.displayName.toLowerCase();
|
||||||
final advertisedName = contact.advName.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();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +217,19 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
final name = contact.displayName.toLowerCase();
|
final name = contact.displayName.toLowerCase();
|
||||||
final advertisedName = contact.advName.toLowerCase();
|
final advertisedName = contact.advName.toLowerCase();
|
||||||
return name.contains(normalizedQuery) ||
|
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(
|
List<_RenderedSavedGroup> _buildSavedGroupsForSection(
|
||||||
@@ -225,7 +241,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
.savedGroupsForSection(section.name)
|
.savedGroupsForSection(section.name)
|
||||||
.map((group) {
|
.map((group) {
|
||||||
final matches = contacts
|
final matches = contacts
|
||||||
.where((contact) => _contactMatchesFilter(contact, group.query))
|
.where((contact) => _contactMatchesSavedGroup(contact, group))
|
||||||
.toList();
|
.toList();
|
||||||
return _RenderedSavedGroup(group: group, contacts: matches);
|
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(
|
Future<void> _toggleSavedGroupForSection(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ContactsProvider contactsProvider,
|
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) {
|
List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
|
||||||
final sorted = List<Contact>.from(contacts);
|
final sorted = List<Contact>.from(contacts);
|
||||||
if (section == ContactSection.channels) {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
@@ -399,6 +610,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
allChatContacts,
|
allChatContacts,
|
||||||
ContactSection.teamMembers,
|
ContactSection.teamMembers,
|
||||||
);
|
);
|
||||||
|
final visibleSavedTeamGroups =
|
||||||
|
_showSavedGroupsForSection(ContactSection.teamMembers)
|
||||||
|
? savedTeamGroups
|
||||||
|
: const <_RenderedSavedGroup>[];
|
||||||
final repeaters = _filterContactsForSection(
|
final repeaters = _filterContactsForSection(
|
||||||
allRepeaters,
|
allRepeaters,
|
||||||
ContactSection.repeaters,
|
ContactSection.repeaters,
|
||||||
@@ -408,6 +623,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
allRepeaters,
|
allRepeaters,
|
||||||
ContactSection.repeaters,
|
ContactSection.repeaters,
|
||||||
);
|
);
|
||||||
|
final visibleSavedRepeaterGroups =
|
||||||
|
_showSavedGroupsForSection(ContactSection.repeaters)
|
||||||
|
? savedRepeaterGroups
|
||||||
|
: const <_RenderedSavedGroup>[];
|
||||||
final rooms = _filterContactsForSection(
|
final rooms = _filterContactsForSection(
|
||||||
allRooms,
|
allRooms,
|
||||||
ContactSection.rooms,
|
ContactSection.rooms,
|
||||||
@@ -417,6 +636,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
allRooms,
|
allRooms,
|
||||||
ContactSection.rooms,
|
ContactSection.rooms,
|
||||||
);
|
);
|
||||||
|
final visibleSavedRoomGroups =
|
||||||
|
_showSavedGroupsForSection(ContactSection.rooms)
|
||||||
|
? savedRoomGroups
|
||||||
|
: const <_RenderedSavedGroup>[];
|
||||||
final filteredChannels = _filterContactsForSection(
|
final filteredChannels = _filterContactsForSection(
|
||||||
allChannels,
|
allChannels,
|
||||||
ContactSection.channels,
|
ContactSection.channels,
|
||||||
@@ -426,6 +649,29 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
allChannels,
|
allChannels,
|
||||||
ContactSection.channels,
|
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;
|
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||||
|
|
||||||
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
|
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
|
||||||
@@ -470,7 +716,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
children: [
|
children: [
|
||||||
// Team Members (Chat contacts)
|
// Team Members (Chat contacts)
|
||||||
if (allChatContacts.isNotEmpty) ...[
|
if (showTeamMembersSection) ...[
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: l10n.teamMembers,
|
title: l10n.teamMembers,
|
||||||
count: chatContacts.length,
|
count: chatContacts.length,
|
||||||
@@ -484,23 +730,32 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
context,
|
context,
|
||||||
ContactSection.teamMembers,
|
ContactSection.teamMembers,
|
||||||
contactsProvider,
|
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',
|
||||||
),
|
),
|
||||||
if (chatContacts.isEmpty)
|
|
||||||
_buildEmptyFilterState(context)
|
|
||||||
else ...[
|
|
||||||
..._buildSavedGroupCards(
|
..._buildSavedGroupCards(
|
||||||
savedTeamGroups,
|
visibleSavedTeamGroups,
|
||||||
ContactSection.teamMembers,
|
ContactSection.teamMembers,
|
||||||
),
|
),
|
||||||
..._buildContactSectionItems(
|
..._buildContactSectionItems(
|
||||||
_excludeGroupedContacts(chatContacts, savedTeamGroups),
|
_excludeGroupedContacts(
|
||||||
|
chatContacts,
|
||||||
|
visibleSavedTeamGroups,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Repeaters
|
// Repeaters
|
||||||
if (allRepeaters.isNotEmpty) ...[
|
if (showRepeatersSection) ...[
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: l10n.repeaters,
|
title: l10n.repeaters,
|
||||||
count: repeaters.length,
|
count: repeaters.length,
|
||||||
@@ -511,23 +766,34 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
context,
|
context,
|
||||||
ContactSection.repeaters,
|
ContactSection.repeaters,
|
||||||
contactsProvider,
|
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',
|
||||||
),
|
),
|
||||||
if (repeaters.isEmpty)
|
|
||||||
_buildEmptyFilterState(context)
|
|
||||||
else ...[
|
|
||||||
..._buildSavedGroupCards(
|
..._buildSavedGroupCards(
|
||||||
savedRepeaterGroups,
|
visibleSavedRepeaterGroups,
|
||||||
ContactSection.repeaters,
|
ContactSection.repeaters,
|
||||||
),
|
),
|
||||||
..._buildContactSectionItems(
|
..._buildContactSectionItems(
|
||||||
_excludeGroupedContacts(repeaters, savedRepeaterGroups),
|
_excludeGroupedContacts(
|
||||||
|
repeaters,
|
||||||
|
visibleSavedRepeaterGroups,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Rooms
|
// Rooms
|
||||||
if (allRooms.isNotEmpty) ...[
|
if (showRoomsSection) ...[
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: l10n.rooms,
|
title: l10n.rooms,
|
||||||
count: rooms.length,
|
count: rooms.length,
|
||||||
@@ -539,17 +805,13 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
ContactSection.rooms,
|
ContactSection.rooms,
|
||||||
contactsProvider,
|
contactsProvider,
|
||||||
),
|
),
|
||||||
if (rooms.isEmpty)
|
|
||||||
_buildEmptyFilterState(context)
|
|
||||||
else ...[
|
|
||||||
..._buildSavedGroupCards(
|
..._buildSavedGroupCards(
|
||||||
savedRoomGroups,
|
visibleSavedRoomGroups,
|
||||||
ContactSection.rooms,
|
ContactSection.rooms,
|
||||||
),
|
),
|
||||||
..._buildContactSectionItems(
|
..._buildContactSectionItems(
|
||||||
_excludeGroupedContacts(rooms, savedRoomGroups),
|
_excludeGroupedContacts(rooms, visibleSavedRoomGroups),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -575,6 +837,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Channels (visible in both simple and advanced mode)
|
// Channels (visible in both simple and advanced mode)
|
||||||
|
if (showChannelsSection) ...[
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: l10n.channels,
|
title: l10n.channels,
|
||||||
count: filteredChannels.length,
|
count: filteredChannels.length,
|
||||||
@@ -585,25 +848,24 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
ContactSection.channels,
|
ContactSection.channels,
|
||||||
contactsProvider,
|
contactsProvider,
|
||||||
),
|
),
|
||||||
if (allChannels.isNotEmpty && filteredChannels.isEmpty)
|
|
||||||
_buildEmptyFilterState(context),
|
|
||||||
..._buildSavedGroupCards(
|
..._buildSavedGroupCards(
|
||||||
savedChannelGroups,
|
visibleSavedChannelGroups,
|
||||||
ContactSection.channels,
|
ContactSection.channels,
|
||||||
),
|
),
|
||||||
if (filteredChannels.isNotEmpty) ...[
|
if (filteredChannels.isNotEmpty) ...[
|
||||||
..._excludeGroupedContacts(
|
..._excludeGroupedContacts(
|
||||||
filteredChannels,
|
filteredChannels,
|
||||||
savedChannelGroups,
|
visibleSavedChannelGroups,
|
||||||
).map(
|
).map(
|
||||||
(channel) => _ChannelActivityCard(
|
(channel) => _ChannelActivityCard(
|
||||||
channel: channel,
|
channel: channel,
|
||||||
messagesProvider: messagesProvider,
|
messagesProvider: messagesProvider,
|
||||||
contactsProvider: contactsProvider,
|
contactsProvider: contactsProvider,
|
||||||
onNavigateToMessages: widget.onNavigateToMessages,
|
onTap: () => _showChannelActionSheet(context, channel),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
|
||||||
// Add Channel Button (visible in both simple and advanced mode, only show when connected)
|
// Add Channel Button (visible in both simple and advanced mode, only show when connected)
|
||||||
if (context.watch<ConnectionProvider>().deviceInfo.isConnected)
|
if (context.watch<ConnectionProvider>().deviceInfo.isConnected)
|
||||||
@@ -633,30 +895,18 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildContactSectionItems(List<Contact> contacts) {
|
List<Widget> _buildContactSectionItems(List<Contact> contacts) {
|
||||||
final items = ContactGrouping.buildItemsFromSorted(contacts);
|
return contacts
|
||||||
|
.map(
|
||||||
return items.map((item) {
|
(contact) => ContactTile(
|
||||||
if (item.isGroup) {
|
contact: contact,
|
||||||
return _InferredContactGroupCard(
|
|
||||||
label: item.group!.label,
|
|
||||||
contacts: item.group!.contacts,
|
|
||||||
currentPosition: _currentPosition,
|
currentPosition: _currentPosition,
|
||||||
calculateDistance: _calculateDistanceInMeters,
|
calculateDistance: _calculateDistanceInMeters,
|
||||||
formatDistance: _formatDistance,
|
formatDistance: _formatDistance,
|
||||||
onNavigateToMap: widget.onNavigateToMap,
|
onNavigateToMap: widget.onNavigateToMap,
|
||||||
onNavigateToMessages: widget.onNavigateToMessages,
|
onNavigateToMessages: widget.onNavigateToMessages,
|
||||||
);
|
),
|
||||||
}
|
)
|
||||||
|
.toList();
|
||||||
return ContactTile(
|
|
||||||
contact: item.contact!,
|
|
||||||
currentPosition: _currentPosition,
|
|
||||||
calculateDistance: _calculateDistanceInMeters,
|
|
||||||
formatDistance: _formatDistance,
|
|
||||||
onNavigateToMap: widget.onNavigateToMap,
|
|
||||||
onNavigateToMessages: widget.onNavigateToMessages,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Contact> _excludeGroupedContacts(
|
List<Contact> _excludeGroupedContacts(
|
||||||
@@ -690,7 +940,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
onDelete: () => context
|
onDelete: () => context
|
||||||
.read<ContactsProvider>()
|
.read<ContactsProvider>()
|
||||||
.removeSavedGroupById(group.group.id),
|
.removeSavedGroupById(group.group.id),
|
||||||
kindLabel: 'Saved filter',
|
kindLabel: group.group.isAutoGroup ? 'Auto group' : 'Saved filter',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -699,8 +949,11 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
Widget _buildSectionFilterField(
|
Widget _buildSectionFilterField(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ContactSection section,
|
ContactSection section,
|
||||||
ContactsProvider contactsProvider,
|
ContactsProvider contactsProvider, {
|
||||||
) {
|
VoidCallback? onSecondaryAction,
|
||||||
|
IconData? secondaryActionIcon,
|
||||||
|
String? secondaryActionTooltip,
|
||||||
|
}) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
final controller = _filterControllers[section]!;
|
final controller = _filterControllers[section]!;
|
||||||
@@ -784,6 +1037,30 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (hasFilter) ...[
|
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(
|
||||||
padding: const EdgeInsets.only(right: 4),
|
padding: const EdgeInsets.only(right: 4),
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -840,9 +1117,39 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
] else
|
] 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),
|
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) {
|
Widget _buildSortMenu(BuildContext context, ContactSection section) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen;
|
final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen;
|
||||||
@@ -1137,13 +1432,13 @@ class _ChannelActivityCard extends StatelessWidget {
|
|||||||
final Contact channel;
|
final Contact channel;
|
||||||
final MessagesProvider messagesProvider;
|
final MessagesProvider messagesProvider;
|
||||||
final ContactsProvider contactsProvider;
|
final ContactsProvider contactsProvider;
|
||||||
final VoidCallback? onNavigateToMessages;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const _ChannelActivityCard({
|
const _ChannelActivityCard({
|
||||||
required this.channel,
|
required this.channel,
|
||||||
required this.messagesProvider,
|
required this.messagesProvider,
|
||||||
required this.contactsProvider,
|
required this.contactsProvider,
|
||||||
required this.onNavigateToMessages,
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
String _formatRelativeTime(BuildContext context, DateTime when) {
|
String _formatRelativeTime(BuildContext context, DateTime when) {
|
||||||
@@ -1207,17 +1502,7 @@ class _ChannelActivityCard extends StatelessWidget {
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
onTap: () async {
|
onTap: onTap,
|
||||||
await MessageDestinationPreferences.setDestination(
|
|
||||||
MessageDestinationPreferences.destinationTypeChannel,
|
|
||||||
recipientPublicKey: channel.publicKeyHex,
|
|
||||||
);
|
|
||||||
messagesProvider.navigateToDestination(
|
|
||||||
MessageDestinationPreferences.destinationTypeChannel,
|
|
||||||
recipientPublicKeyHex: channel.publicKeyHex,
|
|
||||||
);
|
|
||||||
onNavigateToMessages?.call();
|
|
||||||
},
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
child: Row(
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
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',
|
label: 'RX packets',
|
||||||
value: '${snapshot.rxCount}',
|
value: '${snapshot.rxCount}',
|
||||||
subtitle: totalRxCount == null
|
subtitle: totalRxCount == null
|
||||||
? 'Last 60 sec'
|
? _windowSummaryLabel(snapshot.windowDuration)
|
||||||
: 'Device total $totalRxCount',
|
: 'Device total $totalRxCount',
|
||||||
),
|
),
|
||||||
_MetricTile(
|
_MetricTile(
|
||||||
@@ -504,10 +504,13 @@ class _FilterChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _windowLabel(Duration duration) {
|
String _windowLabel(Duration duration) {
|
||||||
if (duration.inMinutes >= 60) {
|
|
||||||
return '${duration.inMinutes} min';
|
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 {
|
class _MetricTile extends StatelessWidget {
|
||||||
@@ -527,7 +530,7 @@ class _MetricTile extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
width: 160,
|
width: 160,
|
||||||
height: 128,
|
height: 136,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.82),
|
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/background_location_service.dart';
|
||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
import '../services/map_marker_service.dart';
|
import '../services/map_marker_service.dart';
|
||||||
|
import '../services/message_destination_preferences.dart';
|
||||||
import '../services/trail_color_service.dart';
|
import '../services/trail_color_service.dart';
|
||||||
import '../widgets/map_debug_info.dart';
|
import '../widgets/map_debug_info.dart';
|
||||||
import '../widgets/map/compass_widget.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
|
/// Show SAR dialog with pre-populated location from map long press
|
||||||
void _showSarDialogWithLocation(LatLng location) {
|
void _showSarDialogWithLocation(LatLng location) {
|
||||||
// Create a Position object from the LatLng coordinates
|
// Create a Position object from the LatLng coordinates
|
||||||
@@ -1119,9 +1288,26 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
|
||||||
return Consumer3<ContactsProvider, MessagesProvider, DrawingProvider>(
|
return Consumer4<
|
||||||
builder: (context, contactsProvider, messagesProvider, drawingProvider, child) {
|
ContactsProvider,
|
||||||
final contactsWithLocation = contactsProvider.contactsWithLocation;
|
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
|
// Filter SAR markers based on visibility toggle
|
||||||
final allSarMarkers = messagesProvider.sarMarkers;
|
final allSarMarkers = messagesProvider.sarMarkers;
|
||||||
final sarMarkers = drawingProvider.showSarMarkers
|
final sarMarkers = drawingProvider.showSarMarkers
|
||||||
@@ -1719,7 +1905,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
onTap: (contact) {
|
onTap: (contact) {
|
||||||
_showDetailedCompassWithContact(
|
_showDetailedCompassWithContact(
|
||||||
context,
|
context,
|
||||||
contactsProvider.contactsWithLocation,
|
contactsWithLocation,
|
||||||
messagesProvider.sarMarkers,
|
messagesProvider.sarMarkers,
|
||||||
contact,
|
contact,
|
||||||
);
|
);
|
||||||
@@ -1731,9 +1917,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
context: context,
|
context: context,
|
||||||
mapRotation: _getMapRotation(),
|
mapRotation: _getMapRotation(),
|
||||||
onTap: (marker) {
|
onTap: (marker) {
|
||||||
// Navigate to the corresponding message in Messages tab
|
_showSarMarkerActions(
|
||||||
messagesProvider.navigateToMessage(marker.id);
|
marker,
|
||||||
widget.onNavigateToMessages?.call();
|
messagesProvider,
|
||||||
|
contactsProvider,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// User location marker with directional pointer
|
// User location marker with directional pointer
|
||||||
@@ -2021,7 +2209,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => _showDetailedCompass(
|
onTap: () => _showDetailedCompass(
|
||||||
context,
|
context,
|
||||||
contactsProvider.contactsWithLocation,
|
contactsWithLocation,
|
||||||
messagesProvider.sarMarkers,
|
messagesProvider.sarMarkers,
|
||||||
),
|
),
|
||||||
child: CompassWidget(
|
child: CompassWidget(
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
int _messageByteCount = 0;
|
int _messageByteCount = 0;
|
||||||
String? _highlightedMessageId;
|
String? _highlightedMessageId;
|
||||||
Timer? _highlightTimer; // Timer for clearing message highlight
|
Timer? _highlightTimer; // Timer for clearing message highlight
|
||||||
|
TextEditingValue _lastComposerValue = const TextEditingValue();
|
||||||
|
bool _isMentionPickerOpen = false;
|
||||||
|
bool _suppressMentionTrigger = false;
|
||||||
|
|
||||||
// Message destination state
|
// Message destination state
|
||||||
String _destinationType =
|
String _destinationType =
|
||||||
@@ -85,7 +88,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_textController.addListener(_updateCharacterCount);
|
_lastComposerValue = _textController.value;
|
||||||
|
_textController.addListener(_handleComposerChanged);
|
||||||
// Load saved message destination
|
// Load saved message destination
|
||||||
_loadSavedDestination();
|
_loadSavedDestination();
|
||||||
_loadVoiceSettings();
|
_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() {
|
void _updateCharacterCount() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_messageByteCount = utf8.encode(_textController.text).length;
|
_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 =>
|
int get _maxMessageBytes =>
|
||||||
_destinationType == MessageDestinationPreferences.destinationTypeChannel
|
_destinationType == MessageDestinationPreferences.destinationTypeChannel
|
||||||
? _maxChannelMessageBytes
|
? _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
|
/// Handle recipient selection
|
||||||
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
|
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -338,13 +453,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _insertReplyMention(String displayName) {
|
void _insertReplyMention(String displayName, {TextRange? replacementRange}) {
|
||||||
final trimmedName = displayName.trim();
|
final trimmedName = displayName.trim();
|
||||||
if (trimmedName.isEmpty) return;
|
if (trimmedName.isEmpty) return;
|
||||||
|
|
||||||
final mention = '@[$trimmedName] ';
|
final mention = '@[$trimmedName] ';
|
||||||
final value = _textController.value;
|
final value = _textController.value;
|
||||||
final selection = value.selection;
|
final selection = replacementRange ?? value.selection;
|
||||||
final hasSelection =
|
final hasSelection =
|
||||||
selection.isValid &&
|
selection.isValid &&
|
||||||
selection.start >= 0 &&
|
selection.start >= 0 &&
|
||||||
@@ -355,11 +470,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final nextText = value.text.replaceRange(start, end, mention);
|
final nextText = value.text.replaceRange(start, end, mention);
|
||||||
final nextOffset = start + mention.length;
|
final nextOffset = start + mention.length;
|
||||||
|
|
||||||
|
_suppressMentionTrigger = true;
|
||||||
_textController.value = value.copyWith(
|
_textController.value = value.copyWith(
|
||||||
text: nextText,
|
text: nextText,
|
||||||
selection: TextSelection.collapsed(offset: nextOffset),
|
selection: TextSelection.collapsed(offset: nextOffset),
|
||||||
composing: TextRange.empty,
|
composing: TextRange.empty,
|
||||||
);
|
);
|
||||||
|
_lastComposerValue = _textController.value;
|
||||||
|
_suppressMentionTrigger = false;
|
||||||
_enforceMessageByteLimit();
|
_enforceMessageByteLimit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import '../providers/contacts_provider.dart';
|
|||||||
import '../providers/messages_provider.dart';
|
import '../providers/messages_provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
import '../providers/connection_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/location_tracking_service.dart';
|
||||||
import '../services/locale_preferences.dart';
|
import '../services/locale_preferences.dart';
|
||||||
import '../services/mesh_map_nodes_service.dart';
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
@@ -72,6 +74,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _fastLocationUpdatesEnabled = false;
|
bool _fastLocationUpdatesEnabled = false;
|
||||||
double _fastLocationMovementThresholdMeters = 10.0;
|
double _fastLocationMovementThresholdMeters = 10.0;
|
||||||
int _fastLocationActiveCadenceSeconds = 10;
|
int _fastLocationActiveCadenceSeconds = 10;
|
||||||
|
bool _rotateMapWithHeading = false;
|
||||||
|
bool _showMapDebugInfo = false;
|
||||||
|
bool _openMapInFullscreen = false;
|
||||||
bool _isDeveloperModeEnabled = false;
|
bool _isDeveloperModeEnabled = false;
|
||||||
DateTime? _onlineTraceCacheUpdatedAt;
|
DateTime? _onlineTraceCacheUpdatedAt;
|
||||||
bool _isClearingOnlineTraceCache = false;
|
bool _isClearingOnlineTraceCache = false;
|
||||||
@@ -93,6 +98,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_loadFastLocationSettings();
|
_loadFastLocationSettings();
|
||||||
_loadDeveloperMode();
|
_loadDeveloperMode();
|
||||||
_loadOnlineTraceCacheStatus();
|
_loadOnlineTraceCacheStatus();
|
||||||
|
_loadMapPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -177,6 +183,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
await prefs.setBool('show_rx_tx_indicators', value);
|
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 {
|
Future<void> _loadVoicePreferences() async {
|
||||||
final value = await VoiceBitratePreferences.getBitrate();
|
final value = await VoiceBitratePreferences.getBitrate();
|
||||||
if (!mounted) return;
|
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'),
|
_buildSectionHeader('Voice'),
|
||||||
Consumer2<AppProvider, ConnectionProvider>(
|
Consumer2<AppProvider, ConnectionProvider>(
|
||||||
builder: (context, appProvider, connectionProvider, child) =>
|
builder: (context, appProvider, connectionProvider, child) =>
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ class ContactStorageService {
|
|||||||
'outPathLen': contact.outPathLen,
|
'outPathLen': contact.outPathLen,
|
||||||
'outPath': base64Encode(contact.outPath),
|
'outPath': base64Encode(contact.outPath),
|
||||||
'advName': contact.advName,
|
'advName': contact.advName,
|
||||||
|
'nameOverride': contact.nameOverride,
|
||||||
'lastAdvert': contact.lastAdvert,
|
'lastAdvert': contact.lastAdvert,
|
||||||
'advLat': contact.advLat,
|
'advLat': contact.advLat,
|
||||||
'advLon': contact.advLon,
|
'advLon': contact.advLon,
|
||||||
@@ -181,6 +182,7 @@ class ContactStorageService {
|
|||||||
outPathLen: json['outPathLen'] as int,
|
outPathLen: json['outPathLen'] as int,
|
||||||
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
|
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
|
||||||
advName: json['advName'] as String,
|
advName: json['advName'] as String,
|
||||||
|
nameOverride: json['nameOverride'] as String?,
|
||||||
lastAdvert: json['lastAdvert'] as int,
|
lastAdvert: json['lastAdvert'] as int,
|
||||||
advLat: json['advLat'] as int,
|
advLat: json['advLat'] as int,
|
||||||
advLon: json['advLon'] as int,
|
advLon: json['advLon'] as int,
|
||||||
@@ -247,6 +249,8 @@ class ContactStorageService {
|
|||||||
'label': group.label,
|
'label': group.label,
|
||||||
'query': group.query,
|
'query': group.query,
|
||||||
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
|
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
|
||||||
|
'matchPrefixes': group.matchPrefixes,
|
||||||
|
'isAutoGroup': group.isAutoGroup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +264,10 @@ class ContactStorageService {
|
|||||||
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
json['createdAtMillis'] as int,
|
json['createdAtMillis'] as int,
|
||||||
),
|
),
|
||||||
|
matchPrefixes: (json['matchPrefixes'] as List<dynamic>?)
|
||||||
|
?.map((value) => value as String)
|
||||||
|
.toList(),
|
||||||
|
isAutoGroup: json['isAutoGroup'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
|
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
|
||||||
|
|||||||
@@ -218,7 +218,8 @@ class LiveTrafficSummary {
|
|||||||
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
|
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
|
||||||
const txCount = 0;
|
const txCount = 0;
|
||||||
final totalCount = rxCount;
|
final totalCount = rxCount;
|
||||||
final packetsPerMinute = totalCount;
|
final packetsPerMinute =
|
||||||
|
((totalCount * Duration.secondsPerMinute) / window.inSeconds).round();
|
||||||
|
|
||||||
return LiveTrafficSnapshot(
|
return LiveTrafficSnapshot(
|
||||||
windowStart: effectiveStart,
|
windowStart: effectiveStart,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
/// Service for persisting messages to local storage
|
/// Service for persisting messages to local storage
|
||||||
class MessageStorageService {
|
class MessageStorageService {
|
||||||
static const String _messagesKey = 'stored_messages';
|
static const String _messagesKey = 'stored_messages';
|
||||||
|
static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids';
|
||||||
static const String _messageContactLocationsKey =
|
static const String _messageContactLocationsKey =
|
||||||
'stored_message_contact_locations';
|
'stored_message_contact_locations';
|
||||||
static const String _messageReceptionDetailsKey =
|
static const String _messageReceptionDetailsKey =
|
||||||
@@ -269,12 +270,36 @@ class MessageStorageService {
|
|||||||
await prefs.remove(_messageReceptionDetailsKey);
|
await prefs.remove(_messageReceptionDetailsKey);
|
||||||
await prefs.remove(_messageTransferDetailsKey);
|
await prefs.remove(_messageTransferDetailsKey);
|
||||||
await prefs.remove(_messageRouteMetadataKey);
|
await prefs.remove(_messageRouteMetadataKey);
|
||||||
|
await prefs.remove(_removedSarMarkerIdsKey);
|
||||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Set<String>> loadRemovedSarMarkerIds() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final ids = prefs.getStringList(_removedSarMarkerIdsKey) ?? const [];
|
||||||
|
return ids.toSet();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e');
|
||||||
|
return const <String>{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveRemovedSarMarkerIds(Set<String> ids) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setStringList(_removedSarMarkerIdsKey, ids.toList()..sort());
|
||||||
|
debugPrint(
|
||||||
|
'✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [MessageStorage] Error saving removed SAR marker IDs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get storage statistics
|
/// Get storage statistics
|
||||||
Future<Map<String, dynamic>> getStorageStats() async {
|
Future<Map<String, dynamic>> getStorageStats() async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,54 +4,98 @@ class InferredContactGroup {
|
|||||||
final String key;
|
final String key;
|
||||||
final String label;
|
final String label;
|
||||||
final List<Contact> contacts;
|
final List<Contact> contacts;
|
||||||
|
final List<String> matchPrefixes;
|
||||||
|
|
||||||
const InferredContactGroup({
|
const InferredContactGroup({
|
||||||
required this.key,
|
required this.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.contacts,
|
required this.contacts,
|
||||||
});
|
List<String>? matchPrefixes,
|
||||||
|
}) : matchPrefixes = matchPrefixes ?? const <String>[];
|
||||||
|
|
||||||
DateTime get latestSeen => contacts.first.lastSeenTime;
|
DateTime get latestSeen => contacts.first.lastSeenTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ContactListItem {
|
|
||||||
final Contact? contact;
|
|
||||||
final InferredContactGroup? group;
|
|
||||||
|
|
||||||
const ContactListItem._({this.contact, this.group});
|
|
||||||
|
|
||||||
const ContactListItem.contact(Contact contact) : this._(contact: contact);
|
|
||||||
|
|
||||||
const ContactListItem.group(InferredContactGroup group)
|
|
||||||
: this._(group: group);
|
|
||||||
|
|
||||||
bool get isGroup => group != null;
|
|
||||||
|
|
||||||
DateTime get latestSeen => group?.latestSeen ?? contact!.lastSeenTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ContactGrouping {
|
class ContactGrouping {
|
||||||
static final RegExp _prefixedNamePattern = RegExp(
|
static final RegExp _prefixedNamePattern = RegExp(
|
||||||
r'^([A-Za-z0-9]{2,})([-_/:])',
|
r'^([A-Za-z0-9]{2,})([-_/:])',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static String? inferredGroupLabelForContact(Contact contact) {
|
||||||
|
return _extractPrefix(contact.displayName)?.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool contactMatchesInferredGroupLabel(Contact contact, String query) {
|
||||||
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (normalizedQuery.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final groupLabel = inferredGroupLabelForContact(contact)?.toLowerCase();
|
||||||
|
return groupLabel?.contains(normalizedQuery) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? sharedInferredGroupLabel(List<Contact> contacts) {
|
||||||
|
String? sharedLabel;
|
||||||
|
for (final contact in contacts) {
|
||||||
|
final label = inferredGroupLabelForContact(contact);
|
||||||
|
if (label == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (sharedLabel == null) {
|
||||||
|
sharedLabel = label;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sharedLabel != label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sharedLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? sharedParentGroupLabel(List<String> labels) {
|
||||||
|
if (labels.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var commonPrefix = labels.first;
|
||||||
|
for (final label in labels.skip(1)) {
|
||||||
|
final maxLength = commonPrefix.length < label.length
|
||||||
|
? commonPrefix.length
|
||||||
|
: label.length;
|
||||||
|
var matchLength = 0;
|
||||||
|
while (matchLength < maxLength &&
|
||||||
|
commonPrefix.codeUnitAt(matchLength) ==
|
||||||
|
label.codeUnitAt(matchLength)) {
|
||||||
|
matchLength++;
|
||||||
|
}
|
||||||
|
commonPrefix = commonPrefix.substring(0, matchLength);
|
||||||
|
if (commonPrefix.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final separatorIndex = commonPrefix.lastIndexOf(RegExp(r'[-_/:]'));
|
||||||
|
if (separatorIndex < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final parentLabel = commonPrefix.substring(0, separatorIndex + 1);
|
||||||
|
return parentLabel.length >= 3 ? parentLabel : null;
|
||||||
|
}
|
||||||
|
|
||||||
static List<Contact> sortByLastSeen(List<Contact> contacts) {
|
static List<Contact> sortByLastSeen(List<Contact> contacts) {
|
||||||
return List<Contact>.from(contacts)
|
return List<Contact>.from(contacts)
|
||||||
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<ContactListItem> buildItems(
|
static List<InferredContactGroup> inferGroups(
|
||||||
List<Contact> contacts, {
|
List<Contact> contacts, {
|
||||||
int minGroupSize = 4,
|
int minGroupSize = 4,
|
||||||
|
int? maxNamedGroups,
|
||||||
|
String? overflowGroupLabel,
|
||||||
}) {
|
}) {
|
||||||
final sortedContacts = sortByLastSeen(contacts);
|
final sortedContacts = sortByLastSeen(contacts);
|
||||||
return buildItemsFromSorted(sortedContacts, minGroupSize: minGroupSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<ContactListItem> buildItemsFromSorted(
|
|
||||||
List<Contact> sortedContacts, {
|
|
||||||
int minGroupSize = 4,
|
|
||||||
}) {
|
|
||||||
final groupedContacts = <String, List<Contact>>{};
|
final groupedContacts = <String, List<Contact>>{};
|
||||||
final groupLabels = <String, String>{};
|
final groupLabels = <String, String>{};
|
||||||
|
|
||||||
@@ -62,32 +106,46 @@ class ContactGrouping {
|
|||||||
groupLabels.putIfAbsent(prefix.key, () => prefix.label);
|
groupLabels.putIfAbsent(prefix.key, () => prefix.label);
|
||||||
}
|
}
|
||||||
|
|
||||||
final eligibleGroups = <String, InferredContactGroup>{};
|
final rankedGroups = <InferredContactGroup>[];
|
||||||
for (final entry in groupedContacts.entries) {
|
for (final entry in groupedContacts.entries) {
|
||||||
if (entry.value.length < minGroupSize) continue;
|
if (entry.value.length < minGroupSize) continue;
|
||||||
eligibleGroups[entry.key] = InferredContactGroup(
|
rankedGroups.add(
|
||||||
|
InferredContactGroup(
|
||||||
key: entry.key,
|
key: entry.key,
|
||||||
label: groupLabels[entry.key] ?? entry.key,
|
label: groupLabels[entry.key] ?? entry.key,
|
||||||
contacts: entry.value,
|
contacts: entry.value,
|
||||||
|
matchPrefixes: [groupLabels[entry.key] ?? entry.key],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
rankedGroups.sort((a, b) => b.latestSeen.compareTo(a.latestSeen));
|
||||||
|
|
||||||
final emittedGroups = <String>{};
|
if (maxNamedGroups != null &&
|
||||||
final items = <ContactListItem>[];
|
overflowGroupLabel != null &&
|
||||||
|
rankedGroups.length > maxNamedGroups) {
|
||||||
for (final contact in sortedContacts) {
|
final retainedGroups = rankedGroups.take(maxNamedGroups).toList();
|
||||||
final prefix = _extractPrefix(contact.displayName);
|
final overflowGroups = rankedGroups.skip(maxNamedGroups).toList();
|
||||||
final group = prefix == null ? null : eligibleGroups[prefix.key];
|
final parentLabel = sharedParentGroupLabel([
|
||||||
if (group == null) {
|
for (final group in overflowGroups) ...group.matchPrefixes,
|
||||||
items.add(ContactListItem.contact(contact));
|
]);
|
||||||
continue;
|
if (parentLabel != null) {
|
||||||
}
|
final overflowContacts = overflowGroups
|
||||||
if (emittedGroups.add(group.key)) {
|
.expand((group) => group.contacts)
|
||||||
items.add(ContactListItem.group(group));
|
.toList();
|
||||||
}
|
return [
|
||||||
|
...retainedGroups,
|
||||||
|
InferredContactGroup(
|
||||||
|
key: parentLabel,
|
||||||
|
label: parentLabel,
|
||||||
|
contacts: sortByLastSeen(overflowContacts),
|
||||||
|
matchPrefixes: [parentLabel],
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return rankedGroups;
|
||||||
|
}
|
||||||
|
return rankedGroups;
|
||||||
}
|
}
|
||||||
|
|
||||||
static _GroupPrefix? _extractPrefix(String name) {
|
static _GroupPrefix? _extractPrefix(String name) {
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
builder: (sheetContext) => SafeArea(
|
builder: (sheetContext) => SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -394,6 +395,15 @@ class ContactTile extends StatelessWidget {
|
|||||||
_showTraceSheet(context, contact);
|
_showTraceSheet(context, contact);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (!contact.isPublicChannel)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.edit_outlined),
|
||||||
|
title: const Text('Edit name'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_showNameOverrideDialog(context, contact);
|
||||||
|
},
|
||||||
|
),
|
||||||
if (!contact.isPublicChannel)
|
if (!contact.isPublicChannel)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete, color: Colors.red),
|
leading: const Icon(Icons.delete, color: Colors.red),
|
||||||
@@ -418,6 +428,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,6 +566,61 @@ class ContactTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showNameOverrideDialog(
|
||||||
|
BuildContext context,
|
||||||
|
Contact contact,
|
||||||
|
) async {
|
||||||
|
final controller = TextEditingController(text: contact.nameOverride ?? '');
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
final result = await showDialog<String?>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Edit name'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Custom name',
|
||||||
|
hintText: contact.advName,
|
||||||
|
helperText: 'Leave blank to use the advertised name.',
|
||||||
|
),
|
||||||
|
onSubmitted: (value) {
|
||||||
|
Navigator.of(dialogContext).pop(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: Text(l10n.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(controller.text),
|
||||||
|
child: Text(l10n.save),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.dispose();
|
||||||
|
|
||||||
|
if (result == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.read<ContactsProvider>().setContactNameOverride(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showSetRouteDialog(
|
Future<void> _showSetRouteDialog(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Contact contact,
|
Contact contact,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class RecipientSelectorSheet extends StatefulWidget {
|
|||||||
final Map<String, int> unreadCountsByPublicKey;
|
final Map<String, int> unreadCountsByPublicKey;
|
||||||
final String? currentDestinationType;
|
final String? currentDestinationType;
|
||||||
final String? currentRecipientPublicKey;
|
final String? currentRecipientPublicKey;
|
||||||
|
final bool showAllOption;
|
||||||
final Function(String type, Contact? recipient) onSelect;
|
final Function(String type, Contact? recipient) onSelect;
|
||||||
|
|
||||||
const RecipientSelectorSheet({
|
const RecipientSelectorSheet({
|
||||||
@@ -23,6 +24,7 @@ class RecipientSelectorSheet extends StatefulWidget {
|
|||||||
required this.unreadCountsByPublicKey,
|
required this.unreadCountsByPublicKey,
|
||||||
this.currentDestinationType,
|
this.currentDestinationType,
|
||||||
this.currentRecipientPublicKey,
|
this.currentRecipientPublicKey,
|
||||||
|
this.showAllOption = true,
|
||||||
required this.onSelect,
|
required this.onSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,6 +63,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
final filteredContacts = _filterContacts(widget.contacts);
|
final filteredContacts = _filterContacts(widget.contacts);
|
||||||
final filteredRooms = _filterContacts(widget.rooms);
|
final filteredRooms = _filterContacts(widget.rooms);
|
||||||
final filteredChannels = _filterContacts(widget.channels);
|
final filteredChannels = _filterContacts(widget.channels);
|
||||||
|
final showChannelsSection = widget.channels.isNotEmpty;
|
||||||
|
final showContactsSection = widget.contacts.isNotEmpty;
|
||||||
|
final showRoomsSection = widget.rooms.isNotEmpty;
|
||||||
|
final showAnyRecipients =
|
||||||
|
showChannelsSection || showContactsSection || showRoomsSection;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
@@ -142,6 +149,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
children: [
|
children: [
|
||||||
|
if (widget.showAllOption) ...[
|
||||||
_buildOptionTile(
|
_buildOptionTile(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.all_inbox,
|
icon: Icons.all_inbox,
|
||||||
@@ -154,9 +162,10 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(),
|
if (showAnyRecipients) const Divider(),
|
||||||
|
],
|
||||||
// Channels section
|
// Channels section
|
||||||
if (widget.channels.isNotEmpty) ...[
|
if (showChannelsSection) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
@@ -204,10 +213,12 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
if (showChannelsSection &&
|
||||||
|
(showContactsSection || showRoomsSection))
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
|
||||||
// Contacts section
|
// Contacts section
|
||||||
if (widget.contacts.isNotEmpty) ...[
|
if (showContactsSection) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
@@ -253,10 +264,10 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
||||||
const Divider(),
|
if (showContactsSection && showRoomsSection) const Divider(),
|
||||||
|
|
||||||
// Rooms section
|
// Rooms section
|
||||||
if (widget.rooms.isNotEmpty) ...[
|
if (showRoomsSection) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
@@ -302,9 +313,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Empty state
|
// Empty state
|
||||||
if (widget.contacts.isEmpty &&
|
if (!showAnyRecipients) ...[
|
||||||
widget.rooms.isEmpty &&
|
|
||||||
widget.channels.isEmpty) ...[
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -794,11 +794,9 @@ packages:
|
|||||||
meshcore_client:
|
meshcore_client:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "../meshcore_client"
|
||||||
ref: main
|
relative: true
|
||||||
resolved-ref: bd3744ee21376b81be5f852cd0c1a82c0df40460
|
source: path
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
|
||||||
source: git
|
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ dev_dependencies:
|
|||||||
fake_async: ^1.3.3
|
fake_async: ^1.3.3
|
||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
|
meshcore_client:
|
||||||
|
path: ../meshcore_client
|
||||||
|
|
||||||
# path_provider_foundation 2.6.0 pulls in package:objective_c as a native
|
# path_provider_foundation 2.6.0 pulls in package:objective_c as a native
|
||||||
# asset. That framework has been ending up archived with a macOS platform
|
# asset. That framework has been ending up archived with a macOS platform
|
||||||
# slice and fails App Store validation for iOS uploads.
|
# slice and fails App Store validation for iOS uploads.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:geolocator/geolocator.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:meshcore_sar_app/models/contact.dart';
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact_group.dart';
|
||||||
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||||
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
|
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
|
||||||
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
|
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
|
||||||
@@ -427,6 +428,46 @@ void main() {
|
|||||||
expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001));
|
expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('prefers a local name override and preserves it across refreshes', () {
|
||||||
|
provider.setContactNameOverride(publicKeyHex(publicKey), 'Rescue One');
|
||||||
|
|
||||||
|
final renamed = provider.findContactByKey(publicKey)!;
|
||||||
|
expect(renamed.nameOverride, 'Rescue One');
|
||||||
|
expect(renamed.displayName, 'Rescue One');
|
||||||
|
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: 'Updated Advertised Name',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: (46.0569 * 1e6).toInt(),
|
||||||
|
advLon: (14.5058 * 1e6).toInt(),
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final refreshed = provider.findContactByKey(publicKey)!;
|
||||||
|
expect(refreshed.nameOverride, 'Rescue One');
|
||||||
|
expect(refreshed.displayName, 'Rescue One');
|
||||||
|
expect(refreshed.advName, 'Updated Advertised Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists a local name override across reloads', () async {
|
||||||
|
provider.setContactNameOverride(publicKeyHex(publicKey), 'Rescue One');
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
final reloadedProvider = ContactsProvider();
|
||||||
|
await reloadedProvider.initializeEarly();
|
||||||
|
|
||||||
|
final reloaded = reloadedProvider.findContactByKey(publicKey)!;
|
||||||
|
expect(reloaded.nameOverride, 'Rescue One');
|
||||||
|
expect(reloaded.displayName, 'Rescue One');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('ContactsProvider route updates', () {
|
group('ContactsProvider route updates', () {
|
||||||
@@ -733,5 +774,45 @@ void main() {
|
|||||||
expect(restored.savedGroupsForSection('rooms').first.query, 'ops');
|
expect(restored.savedGroupsForSection('rooms').first.query, 'ops');
|
||||||
expect(restored.savedGroupsForSection('rooms').first.label, 'ops');
|
expect(restored.savedGroupsForSection('rooms').first.label, 'ops');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('replaces persisted auto groups for a section', () async {
|
||||||
|
await provider.replaceAutoGroupsForSection('repeaters', [
|
||||||
|
SavedContactGroup(
|
||||||
|
id: '${ContactsProvider.autoGroupIdPrefix}repeaters_al',
|
||||||
|
sectionKey: 'repeaters',
|
||||||
|
label: 'AL-',
|
||||||
|
query: 'AL-',
|
||||||
|
createdAt: DateTime(2026, 3, 10, 12),
|
||||||
|
matchPrefixes: const ['AL-'],
|
||||||
|
isAutoGroup: true,
|
||||||
|
),
|
||||||
|
SavedContactGroup(
|
||||||
|
id: '${ContactsProvider.autoGroupIdPrefix}repeaters_others',
|
||||||
|
sectionKey: 'repeaters',
|
||||||
|
label: 'Others',
|
||||||
|
query: 'Others',
|
||||||
|
createdAt: DateTime(2026, 3, 10, 12),
|
||||||
|
matchPrefixes: const ['CR-', 'DE-'],
|
||||||
|
isAutoGroup: true,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final restored = ContactsProvider();
|
||||||
|
await restored.initializeEarly();
|
||||||
|
|
||||||
|
expect(restored.savedGroupsForSection('repeaters'), hasLength(2));
|
||||||
|
expect(
|
||||||
|
restored.savedGroupsForSection('repeaters').first.matchPrefixes,
|
||||||
|
isNotEmpty,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
restored.savedGroupsForSection('repeaters').first.isAutoGroup,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String publicKeyHex(Uint8List publicKey) {
|
||||||
|
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,108 @@ void main() {
|
|||||||
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dedupes repeated incoming contact messages with same text', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);
|
||||||
|
|
||||||
|
provider.addMessage(
|
||||||
|
Message(
|
||||||
|
id: 'dup-1',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700001000,
|
||||||
|
text: 'same payload',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
provider.addMessage(
|
||||||
|
Message(
|
||||||
|
id: 'dup-2',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700001999,
|
||||||
|
text: 'same payload',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.messages, hasLength(1));
|
||||||
|
expect(provider.messages.single.id, equals('dup-1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps separate incoming messages when text differs', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);
|
||||||
|
|
||||||
|
provider.addMessage(
|
||||||
|
Message(
|
||||||
|
id: 'unique-1',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700002000,
|
||||||
|
text: 'same payload',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
provider.addMessage(
|
||||||
|
Message(
|
||||||
|
id: 'unique-2',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700002999,
|
||||||
|
text: 'different payload',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.messages, hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removed SAR markers stay hidden after provider restore', () async {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final message = Message(
|
||||||
|
id: 'sar-hidden',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
channelIdx: 0,
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000003,
|
||||||
|
text: 'S:🧑:0:46.0569,14.5058:Hidden marker',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.addMessage(message);
|
||||||
|
expect(
|
||||||
|
provider.sarMarkers.map((marker) => marker.id),
|
||||||
|
contains(message.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
await provider.removeSarMarker(message.id);
|
||||||
|
expect(provider.sarMarkers, isEmpty);
|
||||||
|
|
||||||
|
final restoredProvider = MessagesProvider();
|
||||||
|
await restoredProvider.initialize();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
restoredProvider.messages.map((stored) => stored.id),
|
||||||
|
contains(message.id),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
restoredProvider.sarMarkers.map((marker) => marker.id),
|
||||||
|
isNot(contains(message.id)),
|
||||||
|
);
|
||||||
|
expect(restoredProvider.removedSarMarkerIds, contains(message.id));
|
||||||
|
});
|
||||||
|
|
||||||
test('tracks and persists media transfer counts and downloaders', () async {
|
test('tracks and persists media transfer counts and downloaders', () async {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
final voiceEnvelope = VoiceEnvelope(
|
final voiceEnvelope = VoiceEnvelope(
|
||||||
|
|||||||
92
test/screens/contacts_tab_test.dart
Normal file
92
test/screens/contacts_tab_test.dart
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/map_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/screens/contacts_tab.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
Contact buildChannel({required String name, required int channelIndex}) {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = 0xFF;
|
||||||
|
publicKey[1] = channelIndex;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.channel,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: -1,
|
||||||
|
outPath: Uint8List(0),
|
||||||
|
advName: name,
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpContactsTab(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required List<Contact> channels,
|
||||||
|
}) async {
|
||||||
|
final contactsProvider = ContactsProvider();
|
||||||
|
for (final channel in channels) {
|
||||||
|
contactsProvider.addOrUpdateContact(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider.value(value: contactsProvider),
|
||||||
|
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => MessagesProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => MapProvider()),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const Scaffold(body: ContactsTab()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('private channel activity card shows delete action in sheet', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpContactsTab(
|
||||||
|
tester,
|
||||||
|
channels: [buildChannel(name: 'Ops', channelIndex: 3)],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Ops'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Ops'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Delete Channel'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Delete Channel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Delete Channel'), findsWidgets);
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'Are you sure you want to delete channel "Ops"? This action cannot be undone.',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(find.text('No live traffic yet'), findsOneWidget);
|
expect(find.text('No packets for this filter'), findsOneWidget);
|
||||||
expect(find.text('Quiet'), findsOneWidget);
|
expect(find.text('Quiet'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('1 pkt/min'), findsOneWidget);
|
expect(find.text('1 pkt/min'), findsOneWidget);
|
||||||
expect(find.text('Device total 7'), findsOneWidget);
|
expect(find.text('Device total 7'), findsOneWidget);
|
||||||
expect(find.textContaining('RESP'), findsOneWidget);
|
expect(find.text('Response'), findsWidgets);
|
||||||
expect(find.text('MULTI-HOP'), findsOneWidget);
|
expect(find.text('MULTI-HOP'), findsOneWidget);
|
||||||
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
|
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
|
||||||
});
|
});
|
||||||
@@ -151,7 +151,7 @@ void main() {
|
|||||||
await tester.tap(find.byTooltip('Clear live view'));
|
await tester.tap(find.byTooltip('Clear live view'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('No live traffic yet'), findsOneWidget);
|
expect(find.text('No packets for this filter'), findsOneWidget);
|
||||||
|
|
||||||
now = now.add(const Duration(seconds: 2));
|
now = now.add(const Duration(seconds: 2));
|
||||||
logs.add(
|
logs.add(
|
||||||
@@ -173,7 +173,7 @@ void main() {
|
|||||||
refresh.value += 1;
|
refresh.value += 1;
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('No live traffic yet'), findsNothing);
|
expect(find.text('No packets for this filter'), findsNothing);
|
||||||
expect(find.textContaining('3 bytes'), findsOneWidget);
|
expect(find.textContaining('3 bytes'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,27 @@ void main() {
|
|||||||
expect(snapshot.busyness, LiveTrafficBusyness.quiet);
|
expect(snapshot.busyness, LiveTrafficBusyness.quiet);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizes packet rate for windows longer than one minute', () {
|
||||||
|
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||||
|
final snapshot = LiveTrafficSummary.fromLogs(
|
||||||
|
List.generate(
|
||||||
|
24,
|
||||||
|
(index) => _log(
|
||||||
|
timestamp: now.subtract(Duration(seconds: index * 10)),
|
||||||
|
direction: PacketDirection.rx,
|
||||||
|
rawData: [0x88, 0x00, 0x00],
|
||||||
|
responseCode: 0x88,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
now: now,
|
||||||
|
window: const Duration(minutes: 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshot.totalCount, 24);
|
||||||
|
expect(snapshot.windowDuration, const Duration(minutes: 5));
|
||||||
|
expect(snapshot.packetsPerMinute, 5);
|
||||||
|
});
|
||||||
|
|
||||||
test('supports clearing the live view without mutating source logs', () {
|
test('supports clearing the live view without mutating source logs', () {
|
||||||
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||||
final clearAt = now.subtract(const Duration(seconds: 8));
|
final clearAt = now.subtract(const Duration(seconds: 8));
|
||||||
|
|||||||
@@ -98,4 +98,14 @@ void main() {
|
|||||||
DateTime.fromMillisecondsSinceEpoch(1700000100400),
|
DateTime.fromMillisecondsSinceEpoch(1700000100400),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('persists removed SAR marker IDs', () async {
|
||||||
|
final storage = MessageStorageService();
|
||||||
|
|
||||||
|
await storage.saveRemovedSarMarkerIds({'sar-2', 'sar-1'});
|
||||||
|
|
||||||
|
final restored = await storage.loadRemovedSarMarkerIds();
|
||||||
|
|
||||||
|
expect(restored, equals({'sar-1', 'sar-2'}));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
group('ContactGrouping.buildItems', () {
|
group('ContactGrouping.inferGroups', () {
|
||||||
test(
|
test(
|
||||||
'groups prefixed contacts only when at least four share the prefix',
|
'groups prefixed contacts only when at least four share the prefix',
|
||||||
() {
|
() {
|
||||||
final now = DateTime(2026, 3, 10, 12);
|
final now = DateTime(2026, 3, 10, 12);
|
||||||
final items = ContactGrouping.buildItems([
|
final groups = ContactGrouping.inferGroups([
|
||||||
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
|
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
|
||||||
buildContact(
|
buildContact(
|
||||||
seed: 2,
|
seed: 2,
|
||||||
@@ -55,20 +55,20 @@ void main() {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(items, hasLength(2));
|
expect(groups, hasLength(1));
|
||||||
expect(items.first.isGroup, isTrue);
|
expect(groups.first.label, 'SI-');
|
||||||
expect(items.first.group!.label, 'SI-');
|
expect(groups.first.contacts.map((contact) => contact.displayName), [
|
||||||
expect(
|
'SI-1',
|
||||||
items.first.group!.contacts.map((contact) => contact.displayName),
|
'SI-2',
|
||||||
['SI-1', 'SI-2', 'SI-3', 'SI-4'],
|
'SI-3',
|
||||||
);
|
'SI-4',
|
||||||
expect(items.last.contact!.displayName, 'OTHER');
|
]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test('does not group when only three contacts share a prefix', () {
|
test('does not group when only three contacts share a prefix', () {
|
||||||
final now = DateTime(2026, 3, 10, 12);
|
final now = DateTime(2026, 3, 10, 12);
|
||||||
final items = ContactGrouping.buildItems([
|
final groups = ContactGrouping.inferGroups([
|
||||||
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
|
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
|
||||||
buildContact(
|
buildContact(
|
||||||
seed: 2,
|
seed: 2,
|
||||||
@@ -82,18 +82,12 @@ void main() {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(items, hasLength(3));
|
expect(groups, isEmpty);
|
||||||
expect(items.every((item) => !item.isGroup), isTrue);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('orders groups and ungrouped contacts by latest last seen', () {
|
test('orders groups by latest last seen', () {
|
||||||
final now = DateTime(2026, 3, 10, 12);
|
final now = DateTime(2026, 3, 10, 12);
|
||||||
final items = ContactGrouping.buildItems([
|
final groups = ContactGrouping.inferGroups([
|
||||||
buildContact(
|
|
||||||
seed: 1,
|
|
||||||
name: 'Lone',
|
|
||||||
lastSeen: now.subtract(const Duration(minutes: 1)),
|
|
||||||
),
|
|
||||||
buildContact(seed: 2, name: 'SI-1', lastSeen: now),
|
buildContact(seed: 2, name: 'SI-1', lastSeen: now),
|
||||||
buildContact(
|
buildContact(
|
||||||
seed: 3,
|
seed: 3,
|
||||||
@@ -110,11 +104,261 @@ void main() {
|
|||||||
name: 'SI-4',
|
name: 'SI-4',
|
||||||
lastSeen: now.subtract(const Duration(minutes: 4)),
|
lastSeen: now.subtract(const Duration(minutes: 4)),
|
||||||
),
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 6,
|
||||||
|
name: 'HR-1',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 1)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 7,
|
||||||
|
name: 'HR-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 5)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 8,
|
||||||
|
name: 'HR-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 6)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 9,
|
||||||
|
name: 'HR-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 7)),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(items, hasLength(2));
|
expect(groups, hasLength(2));
|
||||||
expect(items.first.isGroup, isTrue);
|
expect(groups.first.label, 'SI-');
|
||||||
expect(items.last.contact!.displayName, 'Lone');
|
expect(groups.last.label, 'HR-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collapses extra auto-groups into a shared parent prefix', () {
|
||||||
|
final now = DateTime(2026, 3, 10, 12);
|
||||||
|
final groups = ContactGrouping.inferGroups(
|
||||||
|
[
|
||||||
|
buildContact(seed: 1, name: 'AL-1', lastSeen: now),
|
||||||
|
buildContact(
|
||||||
|
seed: 2,
|
||||||
|
name: 'AL-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 1)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 3,
|
||||||
|
name: 'AL-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 2)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 4,
|
||||||
|
name: 'AL-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 3)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 5,
|
||||||
|
name: 'BR-1',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 4)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 6,
|
||||||
|
name: 'BR-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 5)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 7,
|
||||||
|
name: 'BR-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 6)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 8,
|
||||||
|
name: 'BR-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 7)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 9,
|
||||||
|
name: 'HU-PE-1',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 8)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 10,
|
||||||
|
name: 'HU-PE-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 9)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 11,
|
||||||
|
name: 'HU-GA-1',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 10)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 12,
|
||||||
|
name: 'HU-GA-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 11)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 13,
|
||||||
|
name: 'HU-PE-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 12)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 14,
|
||||||
|
name: 'HU-PE-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 13)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 15,
|
||||||
|
name: 'HU-GA-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 14)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 16,
|
||||||
|
name: 'HU-GA-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 15)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
maxNamedGroups: 2,
|
||||||
|
overflowGroupLabel: 'Others',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(groups, hasLength(3));
|
||||||
|
expect(groups[0].label, 'AL-');
|
||||||
|
expect(groups[1].label, 'BR-');
|
||||||
|
expect(groups[2].label, 'HU-');
|
||||||
|
expect(groups[2].contacts.map((contact) => contact.displayName), [
|
||||||
|
'HU-PE-1',
|
||||||
|
'HU-PE-2',
|
||||||
|
'HU-GA-1',
|
||||||
|
'HU-GA-2',
|
||||||
|
'HU-PE-3',
|
||||||
|
'HU-PE-4',
|
||||||
|
'HU-GA-3',
|
||||||
|
'HU-GA-4',
|
||||||
|
]);
|
||||||
|
expect(groups[2].matchPrefixes, ['HU-']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'does not create an Others bucket when overflow has no shared parent',
|
||||||
|
() {
|
||||||
|
final now = DateTime(2026, 3, 10, 12);
|
||||||
|
final groups = ContactGrouping.inferGroups(
|
||||||
|
[
|
||||||
|
buildContact(seed: 1, name: 'AL-1', lastSeen: now),
|
||||||
|
buildContact(
|
||||||
|
seed: 2,
|
||||||
|
name: 'AL-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 1)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 3,
|
||||||
|
name: 'AL-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 2)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 4,
|
||||||
|
name: 'AL-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 3)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 5,
|
||||||
|
name: 'BR-1',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 4)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 6,
|
||||||
|
name: 'BR-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 5)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 7,
|
||||||
|
name: 'BR-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 6)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 8,
|
||||||
|
name: 'BR-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 7)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 9,
|
||||||
|
name: 'CR-1',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 8)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 10,
|
||||||
|
name: 'CR-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 9)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 11,
|
||||||
|
name: 'CR-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 10)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 12,
|
||||||
|
name: 'CR-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 11)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
maxNamedGroups: 2,
|
||||||
|
overflowGroupLabel: 'Others',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(groups.map((group) => group.label), ['AL-', 'BR-', 'CR-']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ContactGrouping.contactMatchesInferredGroupLabel', () {
|
||||||
|
test('matches the inferred auto-group label for prefixed contacts', () {
|
||||||
|
final contact = buildContact(
|
||||||
|
seed: 99,
|
||||||
|
name: 'SI-1',
|
||||||
|
lastSeen: DateTime(2026, 3, 10, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI-'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'si'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when a contact has no inferred auto-group label', () {
|
||||||
|
final contact = buildContact(
|
||||||
|
seed: 100,
|
||||||
|
name: 'Lone Contact',
|
||||||
|
lastSeen: DateTime(2026, 3, 10, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds a shared inferred label for related repeater names', () {
|
||||||
|
final contacts = [
|
||||||
|
buildContact(
|
||||||
|
seed: 101,
|
||||||
|
name: 'HU-PE',
|
||||||
|
lastSeen: DateTime(2026, 3, 10, 12),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 102,
|
||||||
|
name: 'HU-GA',
|
||||||
|
lastSeen: DateTime(2026, 3, 10, 11, 59),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(ContactGrouping.sharedInferredGroupLabel(contacts), 'HU-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds a shared parent label for related subgroup prefixes', () {
|
||||||
|
expect(
|
||||||
|
ContactGrouping.sharedParentGroupLabel(['HU-PE-', 'HU-GA-']),
|
||||||
|
'HU-',
|
||||||
|
);
|
||||||
|
expect(ContactGrouping.sharedParentGroupLabel(['AL-', 'BR-']), isNull);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,4 +82,17 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('Trace'), findsNothing);
|
expect(find.text('Trace'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('shows overridden contact name as primary label', (tester) async {
|
||||||
|
await pumpTile(
|
||||||
|
tester,
|
||||||
|
buildContact(
|
||||||
|
name: 'John Smith',
|
||||||
|
type: ContactType.chat,
|
||||||
|
).copyWith(nameOverride: 'Rescue One'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Rescue One'), findsOneWidget);
|
||||||
|
expect(find.text('John Smith'), findsNothing);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,4 +71,31 @@ void main() {
|
|||||||
expect(find.text('7'), findsOneWidget);
|
expect(find.text('7'), findsOneWidget);
|
||||||
expect(find.text('3'), findsOneWidget);
|
expect(find.text('3'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('can hide show all option for contact-only flows', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final contact = buildContact(name: 'John Smith', type: ContactType.chat);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: RecipientSelectorSheet(
|
||||||
|
contacts: [contact],
|
||||||
|
rooms: const [],
|
||||||
|
channels: const [],
|
||||||
|
unreadCount: 0,
|
||||||
|
unreadCountsByPublicKey: const {},
|
||||||
|
showAllOption: false,
|
||||||
|
onSelect: (_, __) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Show all'), findsNothing);
|
||||||
|
expect(find.text('John Smith'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user