mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-15 07:04:26 +10:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64d75dde45 | |||
| 9199aab7f7 | |||
| 60e8ee0130 | |||
| 6dfb7a4b69 | |||
| 28a423e0a8 | |||
| 3593cfa843 | |||
| dc85e7a41c | |||
| 9265daaf16 | |||
| 4b744184c2 | |||
| 64698e0be6 | |||
| 3dd9037be3 | |||
| 566e3aadf8 | |||
| 06a906f4f7 | |||
| 054a84031e | |||
| 86e9b7fe01 | |||
| 24fa78741b | |||
| 79a45c527b |
@@ -294,6 +294,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
List<Contact> get allContacts => List.unmodifiable([
|
||||
..._contacts,
|
||||
..._discoveredContacts.where((c) => !c.isActive),
|
||||
]);
|
||||
List<Contact> get discoveredContacts {
|
||||
return List.unmodifiable(_discoveredContacts);
|
||||
}
|
||||
@@ -726,6 +730,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_knownContactKeys
|
||||
..clear()
|
||||
..addAll(cached.map((c) => c.publicKeyHex));
|
||||
_contacts
|
||||
..clear()
|
||||
..addAll(cached);
|
||||
for (final contact in cached) {
|
||||
_ensureContactSmazSettingLoaded(contact.publicKeyHex);
|
||||
}
|
||||
@@ -1558,6 +1565,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
if (_activeTransport == MeshCoreTransportType.usb) {
|
||||
await _usbManager.write(data);
|
||||
// Brief pause so the device firmware can process each frame before the
|
||||
// next arrives. Without this, rapid-fire frames over USB can cause the
|
||||
// device to miss responses (especially on reconnect).
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
} else if (_activeTransport == MeshCoreTransportType.tcp) {
|
||||
await _tcpConnector.write(data);
|
||||
} else {
|
||||
@@ -2962,6 +2973,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
void _handleContact(Uint8List frame, {bool isContact = true}) {
|
||||
final contact = Contact.fromFrame(frame);
|
||||
if (contact != null) {
|
||||
_handleDiscovery(contact, frame, noNotify: true, addActive: true);
|
||||
|
||||
if (contact.type == advTypeRepeater) {
|
||||
_contactUnreadCount.remove(contact.publicKeyHex);
|
||||
_unreadStore.saveContactUnreadCount(
|
||||
@@ -4770,6 +4783,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
(_autoAddRoomServers && type == advTypeRoom) ||
|
||||
(_autoAddSensors && type == advTypeSensor)) {
|
||||
_handleContactAdvert(newContact);
|
||||
_handleDiscovery(
|
||||
newContact,
|
||||
rawPacket,
|
||||
noNotify: true,
|
||||
addActive: true,
|
||||
);
|
||||
} else {
|
||||
_handleDiscovery(newContact, rawPacket);
|
||||
}
|
||||
@@ -4794,8 +4813,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
// CRITICAL: Preserve user's path override when contact is refreshed from device
|
||||
_contacts[existingIndex] = existing.copyWith(
|
||||
latitude: hasLocation ? latitude : existing.latitude,
|
||||
longitude: hasLocation ? longitude : existing.longitude,
|
||||
latitude:
|
||||
hasLocation &&
|
||||
latitude != null &&
|
||||
latitude.abs() <= 90 &&
|
||||
(latitude != 0 || longitude != 0)
|
||||
? latitude
|
||||
: existing.latitude,
|
||||
longitude:
|
||||
hasLocation &&
|
||||
longitude != null &&
|
||||
longitude.abs() <= 180 &&
|
||||
(latitude != 0 || longitude != 0)
|
||||
? longitude
|
||||
: existing.longitude,
|
||||
name: hasName ? name : existing.name,
|
||||
path: Uint8List.fromList(path.reversed.toList()),
|
||||
pathLength: path.length,
|
||||
@@ -4866,11 +4897,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
try {
|
||||
reader.skipBytes(1); // Skip the response code byte
|
||||
final flags = reader.readByte();
|
||||
_autoAddUsers = flags & autoAddChatFlag != 0;
|
||||
_autoAddRepeaters = flags & autoAddRepeaterFlag != 0;
|
||||
_autoAddRoomServers = flags & autoAddRoomServerFlag != 0;
|
||||
_autoAddSensors = flags & autoAddSensorFlag != 0;
|
||||
_overwriteOldest = flags & autoAddOverwriteOldestFlag != 0;
|
||||
_autoAddUsers = (flags & autoAddChatFlag) != 0;
|
||||
_autoAddRepeaters = (flags & autoAddRepeaterFlag) != 0;
|
||||
_autoAddRoomServers = (flags & autoAddRoomServerFlag) != 0;
|
||||
_autoAddSensors = (flags & autoAddSensorFlag) != 0;
|
||||
_overwriteOldest = (flags & autoAddOverwriteOldestFlag) != 0;
|
||||
} catch (e) {
|
||||
appLogger.error('Failed to parse auto-add config: $e', tag: 'Connector');
|
||||
}
|
||||
@@ -4880,6 +4911,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
Contact contact,
|
||||
Uint8List rawPacket, {
|
||||
bool noNotify = false,
|
||||
bool addActive = false,
|
||||
}) {
|
||||
appLogger.info('Discovered new contact: ${contact.name}', tag: 'Connector');
|
||||
|
||||
@@ -4900,7 +4932,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
longitude: contact.longitude,
|
||||
lastSeen: contact.lastSeen,
|
||||
flags: 0,
|
||||
isActive: false,
|
||||
isActive: addActive,
|
||||
);
|
||||
notifyListeners();
|
||||
unawaited(_persistDiscoveredContacts());
|
||||
@@ -4918,7 +4950,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
longitude: contact.longitude,
|
||||
lastSeen: contact.lastSeen,
|
||||
lastMessageAt: contact.lastMessageAt,
|
||||
isActive: false,
|
||||
isActive: addActive,
|
||||
flags: 0,
|
||||
);
|
||||
_discoveredContacts.add(disContact);
|
||||
|
||||
@@ -64,6 +64,8 @@ class MeshCoreUsbManager {
|
||||
|
||||
Future<void> write(Uint8List data) => _service.write(data);
|
||||
|
||||
Future<void> writeRaw(Uint8List data) => _service.writeRaw(data);
|
||||
|
||||
// --- Label management ---
|
||||
void updateConnectedLabel(String selfName) {
|
||||
_service.updateConnectedLabel(selfName);
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"contacts_newGroup": "Нова група",
|
||||
"contacts_groupName": "Група",
|
||||
"contacts_groupNameRequired": "Името на групата е задължително.",
|
||||
"contacts_groupNameReserved": "Това име на група е запазено",
|
||||
"contacts_groupAlreadyExists": "Групата \"{name}\" вече съществува.",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"contacts_newGroup": "Neue Gruppe",
|
||||
"contacts_groupName": "Gruppenname",
|
||||
"contacts_groupNameRequired": "Der Gruppennamen ist erforderlich.",
|
||||
"contacts_groupNameReserved": "Dieser Gruppenname ist reserviert",
|
||||
"contacts_groupAlreadyExists": "Die Gruppe \"{name}\" existiert bereits.",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
"contacts_newGroup": "New Group",
|
||||
"contacts_groupName": "Group name",
|
||||
"contacts_groupNameRequired": "Group name is required",
|
||||
"contacts_groupNameReserved": "This group name is reserved",
|
||||
"contacts_groupAlreadyExists": "Group \"{name}\" already exists",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"contacts_newGroup": "Nuevo Grupo",
|
||||
"contacts_groupName": "Nombre del grupo",
|
||||
"contacts_groupNameRequired": "El nombre del grupo es obligatorio",
|
||||
"contacts_groupNameReserved": "Este nombre de grupo está reservado",
|
||||
"contacts_groupAlreadyExists": "El grupo \"{name}\" ya existe",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"contacts_newGroup": "Nouveau Groupe",
|
||||
"contacts_groupName": "Nom du groupe",
|
||||
"contacts_groupNameRequired": "Le nom du groupe est obligatoire.",
|
||||
"contacts_groupNameReserved": "Ce nom de groupe est réservé",
|
||||
"contacts_groupAlreadyExists": "Le groupe \"{name}\" existe déjà.",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"contacts_newGroup": "Nuovo Gruppo",
|
||||
"contacts_groupName": "Nome gruppo",
|
||||
"contacts_groupNameRequired": "Il nome del gruppo è obbligatorio.",
|
||||
"contacts_groupNameReserved": "Questo nome del gruppo è riservato",
|
||||
"contacts_groupAlreadyExists": "Il gruppo \"{name}\" esiste già.",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -1714,6 +1714,12 @@ abstract class AppLocalizations {
|
||||
/// **'Group name is required'**
|
||||
String get contacts_groupNameRequired;
|
||||
|
||||
/// No description provided for @contacts_groupNameReserved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This group name is reserved'**
|
||||
String get contacts_groupNameReserved;
|
||||
|
||||
/// No description provided for @contacts_groupAlreadyExists.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -902,6 +902,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => 'Името на групата е задължително.';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved => 'Това име на група е запазено';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return 'Групата \"$name\" вече съществува.';
|
||||
|
||||
@@ -902,6 +902,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => 'Der Gruppennamen ist erforderlich.';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved => 'Dieser Gruppenname ist reserviert';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return 'Die Gruppe \"$name\" existiert bereits.';
|
||||
|
||||
@@ -889,6 +889,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => 'Group name is required';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved => 'This group name is reserved';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return 'Group \"$name\" already exists';
|
||||
|
||||
@@ -901,6 +901,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => 'El nombre del grupo es obligatorio';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved =>
|
||||
'Este nombre de grupo está reservado';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return 'El grupo \"$name\" ya existe';
|
||||
|
||||
@@ -905,6 +905,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => 'Le nom du groupe est obligatoire.';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved => 'Ce nom de groupe est réservé';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return 'Le groupe \"$name\" existe déjà.';
|
||||
|
||||
@@ -901,6 +901,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => 'Il nome del gruppo è obbligatorio.';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved => 'Questo nome del gruppo è riservato';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return 'Il gruppo \"$name\" esiste già.';
|
||||
|
||||
@@ -895,6 +895,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => 'De groepnaam is verplicht.';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved => 'Deze groepsnaam is gereserveerd';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return 'De groep \"$name\" bestaat al.';
|
||||
|
||||
@@ -904,6 +904,9 @@ class AppLocalizationsPl extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => 'Nazwa grupy jest wymagana';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved => 'Ta nazwa grupy jest zastrzeżona';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return 'Grupa \"$name\" już istnieje';
|
||||
|
||||
@@ -903,6 +903,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => 'O nome do grupo é obrigatório.';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved => 'Este nome de grupo está reservado';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return 'O grupo \"$name\" já existe';
|
||||
|
||||
@@ -902,6 +902,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => 'Имя группы обязательно';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved => 'Это имя группы зарезервировано';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return 'Группа \"$name\" уже существует';
|
||||
|
||||
@@ -894,6 +894,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => 'Skupina musí mať názov.';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved => 'Tento názov skupiny je rezervovaný';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return 'Skupina \"$name\" už existuje';
|
||||
|
||||
@@ -892,6 +892,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => 'Ime skupine je obvezno.';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved => 'To ime skupine je rezervirano';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return 'Skupina \"$name\" že obstaja';
|
||||
|
||||
@@ -888,6 +888,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => 'Gruppnamnet är obligatoriskt';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved => 'Detta gruppnamn är reserverat';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return 'Gruppen \"$name\" finns redan.';
|
||||
|
||||
@@ -898,6 +898,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => 'Назва групи обов\'язкова.';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved => 'Ця назва групи зарезервована';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return 'Група «$name» вже існує.';
|
||||
|
||||
@@ -845,6 +845,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get contacts_groupNameRequired => '请输入群聊名称';
|
||||
|
||||
@override
|
||||
String get contacts_groupNameReserved => '该群组名称已被保留';
|
||||
|
||||
@override
|
||||
String contacts_groupAlreadyExists(String name) {
|
||||
return '名为 \"$name\" 的群聊已存在';
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"contacts_newGroup": "Nieuwe Groep",
|
||||
"contacts_groupName": "Groepnaam",
|
||||
"contacts_groupNameRequired": "De groepnaam is verplicht.",
|
||||
"contacts_groupNameReserved": "Deze groepsnaam is gereserveerd",
|
||||
"contacts_groupAlreadyExists": "De groep \"{name}\" bestaat al.",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"contacts_newGroup": "Nowa Grupa",
|
||||
"contacts_groupName": "Nazwa grupy",
|
||||
"contacts_groupNameRequired": "Nazwa grupy jest wymagana",
|
||||
"contacts_groupNameReserved": "Ta nazwa grupy jest zastrzeżona",
|
||||
"contacts_groupAlreadyExists": "Grupa \"{name}\" już istnieje",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"contacts_newGroup": "Novo Grupo",
|
||||
"contacts_groupName": "Nome do grupo",
|
||||
"contacts_groupNameRequired": "O nome do grupo é obrigatório.",
|
||||
"contacts_groupNameReserved": "Este nome de grupo está reservado",
|
||||
"contacts_groupAlreadyExists": "O grupo \"{name}\" já existe",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -212,6 +212,7 @@
|
||||
"contacts_newGroup": "Новая группа",
|
||||
"contacts_groupName": "Имя группы",
|
||||
"contacts_groupNameRequired": "Имя группы обязательно",
|
||||
"contacts_groupNameReserved": "Это имя группы зарезервировано",
|
||||
"contacts_groupAlreadyExists": "Группа \"{name}\" уже существует",
|
||||
"contacts_filterContacts": "Фильтр контактов...",
|
||||
"contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру",
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"contacts_newGroup": "Nová skupina",
|
||||
"contacts_groupName": "Názov skupiny",
|
||||
"contacts_groupNameRequired": "Skupina musí mať názov.",
|
||||
"contacts_groupNameReserved": "Tento názov skupiny je rezervovaný",
|
||||
"contacts_groupAlreadyExists": "Skupina \"{name}\" už existuje",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"contacts_newGroup": "Nova skupina",
|
||||
"contacts_groupName": "Ime skupine",
|
||||
"contacts_groupNameRequired": "Ime skupine je obvezno.",
|
||||
"contacts_groupNameReserved": "To ime skupine je rezervirano",
|
||||
"contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"contacts_newGroup": "Ny grupp",
|
||||
"contacts_groupName": "Gruppnamn",
|
||||
"contacts_groupNameRequired": "Gruppnamnet är obligatoriskt",
|
||||
"contacts_groupNameReserved": "Detta gruppnamn är reserverat",
|
||||
"contacts_groupAlreadyExists": "Gruppen \"{name}\" finns redan.",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -286,6 +286,7 @@
|
||||
"contacts_newGroup": "Нова група",
|
||||
"contacts_groupName": "Назва групи",
|
||||
"contacts_groupNameRequired": "Назва групи обов'язкова.",
|
||||
"contacts_groupNameReserved": "Ця назва групи зарезервована",
|
||||
"contacts_groupAlreadyExists": "Група «{name}» вже існує.",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"contacts_newGroup": "新建群聊",
|
||||
"contacts_groupName": "群聊名称",
|
||||
"contacts_groupNameRequired": "请输入群聊名称",
|
||||
"contacts_groupNameReserved": "该群组名称已被保留",
|
||||
"contacts_groupAlreadyExists": "名为 \"{name}\" 的群聊已存在",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'services/app_debug_log_service.dart';
|
||||
import 'services/background_service.dart';
|
||||
import 'services/map_tile_cache_service.dart';
|
||||
import 'services/chat_text_scale_service.dart';
|
||||
import 'services/ui_view_state_service.dart';
|
||||
import 'services/timeout_prediction_service.dart';
|
||||
import 'storage/prefs_manager.dart';
|
||||
import 'utils/app_logger.dart';
|
||||
@@ -40,6 +41,7 @@ void main() async {
|
||||
final backgroundService = BackgroundService();
|
||||
final mapTileCacheService = MapTileCacheService();
|
||||
final chatTextScaleService = ChatTextScaleService();
|
||||
final uiViewStateService = UiViewStateService();
|
||||
final timeoutPredictionService = TimeoutPredictionService(storage);
|
||||
|
||||
// Load settings
|
||||
@@ -58,6 +60,7 @@ void main() async {
|
||||
_registerThirdPartyLicenses();
|
||||
|
||||
await chatTextScaleService.initialize();
|
||||
await uiViewStateService.initialize();
|
||||
await timeoutPredictionService.initialize();
|
||||
|
||||
// Wire up connector with services
|
||||
@@ -90,6 +93,7 @@ void main() async {
|
||||
appDebugLogService: appDebugLogService,
|
||||
mapTileCacheService: mapTileCacheService,
|
||||
chatTextScaleService: chatTextScaleService,
|
||||
uiViewStateService: uiViewStateService,
|
||||
timeoutPredictionService: timeoutPredictionService,
|
||||
),
|
||||
);
|
||||
@@ -126,6 +130,7 @@ class MeshCoreApp extends StatelessWidget {
|
||||
final AppDebugLogService appDebugLogService;
|
||||
final MapTileCacheService mapTileCacheService;
|
||||
final ChatTextScaleService chatTextScaleService;
|
||||
final UiViewStateService uiViewStateService;
|
||||
final TimeoutPredictionService timeoutPredictionService;
|
||||
|
||||
const MeshCoreApp({
|
||||
@@ -139,6 +144,7 @@ class MeshCoreApp extends StatelessWidget {
|
||||
required this.appDebugLogService,
|
||||
required this.mapTileCacheService,
|
||||
required this.chatTextScaleService,
|
||||
required this.uiViewStateService,
|
||||
required this.timeoutPredictionService,
|
||||
});
|
||||
|
||||
@@ -153,6 +159,7 @@ class MeshCoreApp extends StatelessWidget {
|
||||
ChangeNotifierProvider.value(value: bleDebugLogService),
|
||||
ChangeNotifierProvider.value(value: appDebugLogService),
|
||||
ChangeNotifierProvider.value(value: chatTextScaleService),
|
||||
ChangeNotifierProvider.value(value: uiViewStateService),
|
||||
Provider.value(value: storage),
|
||||
Provider.value(value: mapTileCacheService),
|
||||
ChangeNotifierProvider.value(value: timeoutPredictionService),
|
||||
|
||||
@@ -40,6 +40,8 @@ class AppSettings {
|
||||
final UnitSystem unitSystem;
|
||||
final Set<String> mutedChannels;
|
||||
final bool mapShowDiscoveryContacts;
|
||||
final String tcpServerAddress;
|
||||
final int tcpServerPort;
|
||||
|
||||
AppSettings({
|
||||
this.clearPathOnMaxRetry = false,
|
||||
@@ -68,6 +70,8 @@ class AppSettings {
|
||||
this.unitSystem = UnitSystem.metric,
|
||||
Set<String>? mutedChannels,
|
||||
this.mapShowDiscoveryContacts = true,
|
||||
this.tcpServerAddress = '',
|
||||
this.tcpServerPort = 0,
|
||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
|
||||
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
|
||||
mutedChannels = mutedChannels ?? {};
|
||||
@@ -100,6 +104,8 @@ class AppSettings {
|
||||
'unit_system': unitSystem.value,
|
||||
'muted_channels': mutedChannels.toList(),
|
||||
'map_show_discovery_contacts': mapShowDiscoveryContacts,
|
||||
'tcp_server_address': tcpServerAddress,
|
||||
'tcp_server_port': tcpServerPort,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -157,6 +163,8 @@ class AppSettings {
|
||||
{},
|
||||
mapShowDiscoveryContacts:
|
||||
json['map_show_discovery_contacts'] as bool? ?? true,
|
||||
tcpServerAddress: json['tcp_server_address'] as String? ?? '',
|
||||
tcpServerPort: json['tcp_server_port'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,6 +195,8 @@ class AppSettings {
|
||||
UnitSystem? unitSystem,
|
||||
Set<String>? mutedChannels,
|
||||
bool? mapShowDiscoveryContacts,
|
||||
String? tcpServerAddress,
|
||||
int? tcpServerPort,
|
||||
}) {
|
||||
return AppSettings(
|
||||
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
|
||||
@@ -225,6 +235,8 @@ class AppSettings {
|
||||
mutedChannels: mutedChannels ?? this.mutedChannels,
|
||||
mapShowDiscoveryContacts:
|
||||
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
|
||||
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
|
||||
tcpServerPort: tcpServerPort ?? this.tcpServerPort,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-39
@@ -65,7 +65,17 @@ class Contact {
|
||||
return '$pathLength hops';
|
||||
}
|
||||
|
||||
bool get hasLocation => latitude != null && longitude != null;
|
||||
bool get hasLocation {
|
||||
const double epsilon = 1e-6;
|
||||
final lat = latitude ?? 0.0;
|
||||
final lon = longitude ?? 0.0;
|
||||
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
|
||||
lat >= -90.0 &&
|
||||
lat <= 90.0 &&
|
||||
lon >= -180.0 &&
|
||||
lon <= 180.0;
|
||||
}
|
||||
|
||||
bool get isFavorite => (flags & contactFlagFavorite) != 0;
|
||||
|
||||
Contact copyWith({
|
||||
@@ -108,7 +118,7 @@ class Contact {
|
||||
}
|
||||
|
||||
String get pathIdList {
|
||||
final pathBytes = _pathBytesForDisplay;
|
||||
final pathBytes = pathBytesForDisplay;
|
||||
if (pathBytes.isEmpty) return '';
|
||||
final parts = <String>[];
|
||||
final groupSize = pathHashSize;
|
||||
@@ -130,43 +140,7 @@ class Contact {
|
||||
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
|
||||
}
|
||||
|
||||
Uint8List? get traceRouteBytes {
|
||||
final pathBytes = _pathBytesForDisplay;
|
||||
Uint8List? traceBytes;
|
||||
|
||||
if (pathBytes.isEmpty) {
|
||||
traceBytes = Uint8List(1);
|
||||
traceBytes[0] = publicKey[0];
|
||||
return traceBytes;
|
||||
}
|
||||
|
||||
if (type == advTypeRepeater || type == advTypeRoom) {
|
||||
final len = (pathBytes.length + pathBytes.length + 1);
|
||||
traceBytes = Uint8List(len);
|
||||
traceBytes[pathBytes.length] = publicKey[0];
|
||||
for (int i = 0; i < pathBytes.length; i++) {
|
||||
traceBytes[i] = pathBytes[i];
|
||||
if (i < pathBytes.length) {
|
||||
traceBytes[len - 1 - i] = pathBytes[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (pathBytes.length < 2) {
|
||||
return pathBytes[0] == 0 ? null : pathBytes;
|
||||
}
|
||||
final len = (pathBytes.length + pathBytes.length - 1);
|
||||
traceBytes = Uint8List(len);
|
||||
for (int i = 0; i < pathBytes.length; i++) {
|
||||
traceBytes[i] = pathBytes[i];
|
||||
if (i < pathBytes.length - 1) {
|
||||
traceBytes[len - 1 - i] = pathBytes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return traceBytes;
|
||||
}
|
||||
|
||||
Uint8List get _pathBytesForDisplay {
|
||||
Uint8List get pathBytesForDisplay {
|
||||
if (pathOverride != null) {
|
||||
if (pathOverride! < 0) return Uint8List(0);
|
||||
return pathOverrideBytes ?? Uint8List(0);
|
||||
@@ -197,6 +171,7 @@ class Contact {
|
||||
double? lat, lon;
|
||||
final latRaw = reader.readInt32LE();
|
||||
final lonRaw = reader.readInt32LE();
|
||||
|
||||
if (latRaw != 0 || lonRaw != 0) {
|
||||
lat = latRaw / 1e6;
|
||||
lon = lonRaw / 1e6;
|
||||
|
||||
@@ -40,10 +40,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
final primaryPath = !channelMessage && !message.isOutgoing
|
||||
? Uint8List.fromList(primaryPathTmp.reversed.toList())
|
||||
: primaryPathTmp;
|
||||
final contacts = <Contact>[
|
||||
...connector.contacts,
|
||||
...connector.discoveredContacts,
|
||||
];
|
||||
final contacts = connector.allContacts;
|
||||
final hops = _buildPathHops(primaryPath, contacts, l10n);
|
||||
final hasHopDetails = primaryPath.isNotEmpty;
|
||||
final observedLabel = _formatObservedHops(
|
||||
@@ -65,8 +62,9 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: context.l10n.contacts_repeaterPathTrace,
|
||||
path: primaryPath,
|
||||
flipPathRound: true,
|
||||
reversePathRound: !message.isOutgoing && !channelMessage,
|
||||
flipPathAround: true,
|
||||
reversePathAround:
|
||||
!(!channelMessage && !message.isOutgoing),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -367,10 +365,7 @@ class _ChannelMessagePathMapScreenState
|
||||
: selectedPathTmp;
|
||||
|
||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||
final contacts = <Contact>[
|
||||
...connector.contacts,
|
||||
...connector.discoveredContacts,
|
||||
];
|
||||
final contacts = connector.allContacts;
|
||||
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
|
||||
|
||||
final points = <LatLng>[];
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:uuid/uuid.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/ui_view_state_service.dart';
|
||||
import '../models/channel.dart';
|
||||
import '../models/community.dart';
|
||||
import '../storage/community_store.dart';
|
||||
@@ -28,8 +29,6 @@ import 'contacts_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
enum ChannelSortOption { manual, name, latestMessages, unread }
|
||||
|
||||
class ChannelsScreen extends StatefulWidget {
|
||||
final bool hideBackButton;
|
||||
|
||||
@@ -43,9 +42,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
with DisconnectNavigationMixin {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final CommunityStore _communityStore = CommunityStore();
|
||||
String _searchQuery = '';
|
||||
Timer? _searchDebounce;
|
||||
ChannelSortOption _sortOption = ChannelSortOption.manual;
|
||||
List<Community> _communities = [];
|
||||
|
||||
// Cache of PSK hex -> Community for quick lookup
|
||||
@@ -56,6 +53,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController.text = context
|
||||
.read<UiViewStateService>()
|
||||
.channelsSearchText;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<MeshCoreConnector>().getChannels();
|
||||
_loadCommunities();
|
||||
@@ -110,6 +110,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final viewState = context.watch<UiViewStateService>();
|
||||
|
||||
final channelMessageStore = ChannelMessageStore();
|
||||
channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||
@@ -205,6 +206,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
final filteredChannels = _filterAndSortChannels(
|
||||
channels,
|
||||
connector,
|
||||
viewState,
|
||||
);
|
||||
|
||||
return Column(
|
||||
@@ -219,17 +221,19 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_searchQuery.isNotEmpty)
|
||||
if (viewState.channelsSearchText.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = null;
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
context
|
||||
.read<UiViewStateService>()
|
||||
.setChannelsSearchText('');
|
||||
},
|
||||
),
|
||||
_buildFilterButton(),
|
||||
_buildFilterButton(viewState),
|
||||
],
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
@@ -246,9 +250,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
const Duration(milliseconds: 300),
|
||||
() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_searchQuery = value.toLowerCase();
|
||||
});
|
||||
context
|
||||
.read<UiViewStateService>()
|
||||
.setChannelsSearchText(value);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -283,8 +287,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
),
|
||||
],
|
||||
)
|
||||
: (_sortOption == ChannelSortOption.manual &&
|
||||
_searchQuery.isEmpty)
|
||||
: (viewState.channelsSortOption ==
|
||||
ChannelSortOption.manual &&
|
||||
viewState.channelsSearchText.isEmpty)
|
||||
? ReorderableListView.builder(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
@@ -584,59 +589,40 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
await showDisconnectDialog(context, connector);
|
||||
}
|
||||
|
||||
Widget _buildFilterButton() {
|
||||
const actionSortManual = 0;
|
||||
const actionSortName = 1;
|
||||
const actionSortLatest = 2;
|
||||
const actionSortUnread = 3;
|
||||
|
||||
return SortFilterMenu(
|
||||
Widget _buildFilterButton(UiViewStateService viewState) {
|
||||
return SortFilterMenu<ChannelSortOption>(
|
||||
tooltip: context.l10n.listFilter_tooltip,
|
||||
sections: [
|
||||
SortFilterMenuSection(
|
||||
SortFilterMenuSection<ChannelSortOption>(
|
||||
title: context.l10n.channels_sortBy,
|
||||
options: [
|
||||
SortFilterMenuOption(
|
||||
value: actionSortManual,
|
||||
SortFilterMenuOption<ChannelSortOption>(
|
||||
value: ChannelSortOption.manual,
|
||||
label: context.l10n.channels_sortManual,
|
||||
checked: _sortOption == ChannelSortOption.manual,
|
||||
checked: viewState.channelsSortOption == ChannelSortOption.manual,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: actionSortName,
|
||||
SortFilterMenuOption<ChannelSortOption>(
|
||||
value: ChannelSortOption.name,
|
||||
label: context.l10n.channels_sortAZ,
|
||||
checked: _sortOption == ChannelSortOption.name,
|
||||
checked: viewState.channelsSortOption == ChannelSortOption.name,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: actionSortLatest,
|
||||
SortFilterMenuOption<ChannelSortOption>(
|
||||
value: ChannelSortOption.latestMessages,
|
||||
label: context.l10n.channels_sortLatestMessages,
|
||||
checked: _sortOption == ChannelSortOption.latestMessages,
|
||||
checked:
|
||||
viewState.channelsSortOption ==
|
||||
ChannelSortOption.latestMessages,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: actionSortUnread,
|
||||
SortFilterMenuOption<ChannelSortOption>(
|
||||
value: ChannelSortOption.unread,
|
||||
label: context.l10n.channels_sortUnread,
|
||||
checked: _sortOption == ChannelSortOption.unread,
|
||||
checked: viewState.channelsSortOption == ChannelSortOption.unread,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
onSelected: (action) {
|
||||
setState(() {
|
||||
switch (action) {
|
||||
case actionSortManual:
|
||||
_sortOption = ChannelSortOption.manual;
|
||||
break;
|
||||
case actionSortLatest:
|
||||
_sortOption = ChannelSortOption.latestMessages;
|
||||
break;
|
||||
case actionSortUnread:
|
||||
_sortOption = ChannelSortOption.unread;
|
||||
break;
|
||||
case actionSortName:
|
||||
default:
|
||||
_sortOption = ChannelSortOption.name;
|
||||
break;
|
||||
}
|
||||
});
|
||||
onSelected: (sortOption) {
|
||||
viewState.setChannelsSortOption(sortOption);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -644,11 +630,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
List<Channel> _filterAndSortChannels(
|
||||
List<Channel> channels,
|
||||
MeshCoreConnector connector,
|
||||
UiViewStateService viewState,
|
||||
) {
|
||||
var filtered = channels.where((channel) {
|
||||
if (_searchQuery.isEmpty) return true;
|
||||
if (viewState.channelsSearchText.isEmpty) return true;
|
||||
final label = _normalizeChannelName(channel);
|
||||
return label.toLowerCase().contains(_searchQuery);
|
||||
return label.toLowerCase().contains(
|
||||
viewState.channelsSearchText.toLowerCase(),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
int compareByName(Channel a, Channel b) {
|
||||
@@ -657,7 +646,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
|
||||
}
|
||||
|
||||
switch (_sortOption) {
|
||||
switch (viewState.channelsSortOption) {
|
||||
case ChannelSortOption.manual:
|
||||
break;
|
||||
case ChannelSortOption.latestMessages:
|
||||
|
||||
@@ -858,7 +858,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: context.l10n.contacts_repeaterPathTrace,
|
||||
path: Uint8List.fromList(pathBytes),
|
||||
flipPathRound: true,
|
||||
flipPathAround: true,
|
||||
targetContact: widget.contact,
|
||||
),
|
||||
),
|
||||
@@ -1027,7 +1027,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final currentPathLabel = _currentPathLabel(currentContact);
|
||||
|
||||
// Filter out the current contact from available contacts
|
||||
final availableContacts = connector.contacts
|
||||
final availableContacts = connector.allContacts
|
||||
.where((c) => c != widget.contact)
|
||||
.toList();
|
||||
|
||||
|
||||
+465
-285
@@ -13,8 +13,9 @@ import '../l10n/l10n.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/contact_group.dart';
|
||||
import '../storage/contact_group_store.dart';
|
||||
import '../services/ui_view_state_service.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../storage/contact_group_store.dart';
|
||||
import '../utils/dialog_utils.dart';
|
||||
import '../utils/disconnect_navigation_mixin.dart';
|
||||
import '../utils/emoji_utils.dart';
|
||||
@@ -48,12 +49,10 @@ class ContactsScreen extends StatefulWidget {
|
||||
class _ContactsScreenState extends State<ContactsScreen>
|
||||
with DisconnectNavigationMixin {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
ContactSortOption _sortOption = ContactSortOption.lastSeen;
|
||||
bool _showUnreadOnly = false;
|
||||
ContactTypeFilter _typeFilter = ContactTypeFilter.all;
|
||||
final ContactGroupStore _groupStore = ContactGroupStore();
|
||||
MeshCoreConnector? _scopeSyncConnector;
|
||||
List<ContactGroup> _groups = [];
|
||||
String _loadedGroupScopeKeyHex = '';
|
||||
Timer? _searchDebounce;
|
||||
|
||||
final Set<ContactOperationType> _pendingOperations = {};
|
||||
@@ -63,6 +62,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController.text = context
|
||||
.read<UiViewStateService>()
|
||||
.contactsSearchText;
|
||||
_loadGroups();
|
||||
_setupFrameListener();
|
||||
_clearAdvertNotifications();
|
||||
@@ -74,26 +76,84 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
NotificationService().clearAdvertNotifications(contactIds);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
if (!identical(_scopeSyncConnector, connector)) {
|
||||
_scopeSyncConnector?.removeListener(_handleConnectorScopeChange);
|
||||
_scopeSyncConnector = connector;
|
||||
_scopeSyncConnector?.addListener(_handleConnectorScopeChange);
|
||||
}
|
||||
_handleConnectorScopeChange();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchController.dispose();
|
||||
_frameSubscription?.cancel();
|
||||
_scopeSyncConnector?.removeListener(_handleConnectorScopeChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleConnectorScopeChange() {
|
||||
final connector = _scopeSyncConnector;
|
||||
if (connector == null) return;
|
||||
_syncGroupScopeIfNeeded(connector);
|
||||
}
|
||||
|
||||
Future<void> _loadGroups() async {
|
||||
final selfPublicKeyHex = context.read<MeshCoreConnector>().selfPublicKeyHex;
|
||||
if (selfPublicKeyHex.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_groupStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
final groups = await _groupStore.loadGroups();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loadedGroupScopeKeyHex = selfPublicKeyHex;
|
||||
_groups = groups;
|
||||
_ensureValidSelectedGroup();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveGroups() async {
|
||||
final selfPublicKeyHex = context.read<MeshCoreConnector>().selfPublicKeyHex;
|
||||
if (selfPublicKeyHex.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_groupStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
await _groupStore.saveGroups(_groups);
|
||||
}
|
||||
|
||||
bool _hasGroupStoreScope(MeshCoreConnector connector) {
|
||||
return connector.selfPublicKeyHex.isNotEmpty;
|
||||
}
|
||||
|
||||
void _syncGroupScopeIfNeeded(MeshCoreConnector connector) {
|
||||
final selfPublicKeyHex = connector.selfPublicKeyHex;
|
||||
if (selfPublicKeyHex.isEmpty ||
|
||||
selfPublicKeyHex == _loadedGroupScopeKeyHex) {
|
||||
return;
|
||||
}
|
||||
_loadGroups();
|
||||
}
|
||||
|
||||
void _collapseContactsSearch(UiViewStateService viewState) {
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = null;
|
||||
_searchController.clear();
|
||||
viewState.setContactsSearchText('');
|
||||
viewState.setContactsSearchExpanded(false);
|
||||
}
|
||||
|
||||
void _showGroupsUnavailableMessage(BuildContext context) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.common_loading)));
|
||||
}
|
||||
|
||||
void _setupFrameListener() {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
// Listen for incoming text messages from the repeater
|
||||
@@ -383,31 +443,166 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
await showDisconnectDialog(context, connector);
|
||||
}
|
||||
|
||||
Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) {
|
||||
ContactGroup? _selectedGroupForName(String selectedGroupName) {
|
||||
if (selectedGroupName == contactsAllGroupsValue) return null;
|
||||
for (final group in _groups) {
|
||||
if (group.name == selectedGroupName) return group;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _ensureValidSelectedGroup() {
|
||||
final viewState = context.read<UiViewStateService>();
|
||||
if (viewState.contactsSelectedGroupName == contactsAllGroupsValue) return;
|
||||
final exists = _groups.any(
|
||||
(group) => group.name == viewState.contactsSelectedGroupName,
|
||||
);
|
||||
if (!exists) {
|
||||
viewState.setContactsSelectedGroupName(contactsAllGroupsValue);
|
||||
}
|
||||
}
|
||||
|
||||
void _closeDropdownAndRun(BuildContext popupContext, VoidCallback action) {
|
||||
final route = ModalRoute.of(popupContext);
|
||||
if (route != null && route.isCurrent) {
|
||||
Navigator.of(popupContext).pop();
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
action();
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildFilterButton(
|
||||
BuildContext context,
|
||||
UiViewStateService viewState,
|
||||
) {
|
||||
return ContactsFilterMenu(
|
||||
sortOption: _sortOption,
|
||||
typeFilter: _typeFilter,
|
||||
showUnreadOnly: _showUnreadOnly,
|
||||
sortOption: viewState.contactsSortOption,
|
||||
typeFilter: viewState.contactsTypeFilter,
|
||||
showUnreadOnly: viewState.contactsShowUnreadOnly,
|
||||
onSortChanged: (value) {
|
||||
setState(() {
|
||||
_sortOption = value;
|
||||
});
|
||||
viewState.setContactsSortOption(value);
|
||||
},
|
||||
onTypeFilterChanged: (value) {
|
||||
setState(() {
|
||||
_typeFilter = value;
|
||||
});
|
||||
viewState.setContactsTypeFilter(value);
|
||||
},
|
||||
onUnreadOnlyChanged: (value) {
|
||||
setState(() {
|
||||
_showUnreadOnly = value;
|
||||
});
|
||||
viewState.setContactsShowUnreadOnly(value);
|
||||
},
|
||||
onNewGroup: () => _showGroupEditor(context, connector.contacts),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGroupButton(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
UiViewStateService viewState,
|
||||
List<Contact> contacts,
|
||||
List<ContactGroup> sortedGroups,
|
||||
) {
|
||||
final canManageGroups = _hasGroupStoreScope(connector);
|
||||
final selectedGroupName =
|
||||
_selectedGroupForName(viewState.contactsSelectedGroupName)?.name ??
|
||||
context.l10n.listFilter_all;
|
||||
final double menuWidth = (MediaQuery.sizeOf(context).width - 16).clamp(
|
||||
0.0,
|
||||
double.infinity,
|
||||
);
|
||||
|
||||
return PopupMenuButton<String>(
|
||||
position: PopupMenuPosition.under,
|
||||
constraints: BoxConstraints.tightFor(width: menuWidth),
|
||||
onSelected: (String value) {
|
||||
viewState.setContactsSelectedGroupName(value);
|
||||
},
|
||||
itemBuilder: (menuContext) => [
|
||||
PopupMenuItem<String>(
|
||||
value: contactsAllGroupsValue,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(menuContext.l10n.listFilter_all),
|
||||
IconButton(
|
||||
tooltip: menuContext.l10n.contacts_newGroup,
|
||||
icon: const Icon(Icons.group_add, size: 20),
|
||||
onPressed: canManageGroups
|
||||
? () => _closeDropdownAndRun(
|
||||
menuContext,
|
||||
() => _showGroupEditor(this.context, contacts),
|
||||
)
|
||||
: () => _closeDropdownAndRun(
|
||||
menuContext,
|
||||
() => _showGroupsUnavailableMessage(this.context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...sortedGroups.map((group) {
|
||||
return PopupMenuItem<String>(
|
||||
value: group.name,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(group.name, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: menuContext.l10n.contacts_editGroup,
|
||||
icon: const Icon(Icons.edit, size: 20),
|
||||
onPressed: canManageGroups
|
||||
? () => _closeDropdownAndRun(
|
||||
menuContext,
|
||||
() => _showGroupEditor(
|
||||
this.context,
|
||||
contacts,
|
||||
group: group,
|
||||
),
|
||||
)
|
||||
: () => _closeDropdownAndRun(
|
||||
menuContext,
|
||||
() => _showGroupsUnavailableMessage(this.context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: menuContext.l10n.contacts_deleteGroup,
|
||||
icon: const Icon(Icons.delete, size: 20, color: Colors.red),
|
||||
onPressed: canManageGroups
|
||||
? () => _closeDropdownAndRun(
|
||||
menuContext,
|
||||
() => _confirmDeleteGroup(this.context, group),
|
||||
)
|
||||
: () => _closeDropdownAndRun(
|
||||
menuContext,
|
||||
() => _showGroupsUnavailableMessage(this.context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(selectedGroupName, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.arrow_drop_down),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
|
||||
final viewState = context.watch<UiViewStateService>();
|
||||
final contacts = connector.contacts;
|
||||
final shouldShowStartupSpinner =
|
||||
contacts.isEmpty &&
|
||||
@@ -429,92 +624,171 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
);
|
||||
}
|
||||
|
||||
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
|
||||
final filteredGroups = _showUnreadOnly
|
||||
? const <ContactGroup>[]
|
||||
: _filterAndSortGroups(_groups, contacts);
|
||||
final filteredAndSorted = _filterAndSortContacts(
|
||||
contacts,
|
||||
connector,
|
||||
viewState,
|
||||
);
|
||||
|
||||
String hintText = "";
|
||||
|
||||
switch (_typeFilter) {
|
||||
switch (viewState.contactsTypeFilter) {
|
||||
case ContactTypeFilter.all:
|
||||
hintText = context.l10n.contacts_searchContacts(
|
||||
filteredAndSorted.length,
|
||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
viewState.contactsShowUnreadOnly
|
||||
? " ${context.l10n.contacts_unread}"
|
||||
: "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.users:
|
||||
hintText = context.l10n.contacts_searchUsers(
|
||||
filteredAndSorted.length,
|
||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
viewState.contactsShowUnreadOnly
|
||||
? " ${context.l10n.contacts_unread}"
|
||||
: "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.repeaters:
|
||||
hintText = context.l10n.contacts_searchRepeaters(
|
||||
filteredAndSorted.length,
|
||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
viewState.contactsShowUnreadOnly
|
||||
? " ${context.l10n.contacts_unread}"
|
||||
: "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.rooms:
|
||||
hintText = context.l10n.contacts_searchRoomServers(
|
||||
filteredAndSorted.length,
|
||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
viewState.contactsShowUnreadOnly
|
||||
? " ${context.l10n.contacts_unread}"
|
||||
: "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.favorites:
|
||||
hintText = context.l10n.contacts_searchFavorites(
|
||||
filteredAndSorted.length,
|
||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
viewState.contactsShowUnreadOnly
|
||||
? " ${context.l10n.contacts_unread}"
|
||||
: "",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
final groupsByName = <String, ContactGroup>{};
|
||||
for (final group in _groups) {
|
||||
groupsByName.putIfAbsent(group.name, () => group);
|
||||
}
|
||||
final sortedGroups = groupsByName.values.toList()
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final searchExpandedWidth = (screenWidth * 0.52).clamp(
|
||||
97.0,
|
||||
double.infinity,
|
||||
); // allow expansion up to 52% of screen width, but not less than the collapsed width
|
||||
final searchCollapsedWidth = (screenWidth * 0.22).clamp(
|
||||
97.0,
|
||||
120.0,
|
||||
); //two 48px icon buttons + 1px divider
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_searchQuery.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildGroupButton(
|
||||
context,
|
||||
connector,
|
||||
viewState,
|
||||
contacts,
|
||||
sortedGroups,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: viewState.contactsSearchExpanded
|
||||
? searchExpandedWidth
|
||||
: searchCollapsedWidth,
|
||||
height: 48,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
_buildFilterButton(context, connector),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: viewState.contactsSearchExpanded
|
||||
? TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = Timer(
|
||||
const Duration(milliseconds: 300),
|
||||
() {
|
||||
if (!mounted) return;
|
||||
context
|
||||
.read<UiViewStateService>()
|
||||
.setContactsSearchText(value);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
if (viewState.contactsSearchExpanded) {
|
||||
_collapseContactsSearch(viewState);
|
||||
return;
|
||||
}
|
||||
viewState.setContactsSearchExpanded(true);
|
||||
},
|
||||
icon: Icon(
|
||||
viewState.contactsSearchExpanded
|
||||
? Icons.close
|
||||
: Icons.search,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 24,
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: _buildFilterButton(context, viewState),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_searchQuery = value.toLowerCase();
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
|
||||
child: filteredAndSorted.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -522,7 +796,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_showUnreadOnly
|
||||
viewState.contactsShowUnreadOnly
|
||||
? context.l10n.contacts_noUnreadContacts
|
||||
: context.l10n.contacts_noContactsFound,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
@@ -533,14 +807,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
: RefreshIndicator(
|
||||
onRefresh: () => connector.getContacts(),
|
||||
child: ListView.builder(
|
||||
itemCount: filteredGroups.length + filteredAndSorted.length,
|
||||
itemCount: filteredAndSorted.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < filteredGroups.length) {
|
||||
final group = filteredGroups[index];
|
||||
return _buildGroupTile(context, group, contacts);
|
||||
}
|
||||
final contact =
|
||||
filteredAndSorted[index - filteredGroups.length];
|
||||
final contact = filteredAndSorted[index];
|
||||
final unreadCount = connector.getUnreadCountForContact(
|
||||
contact,
|
||||
);
|
||||
@@ -561,55 +830,26 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
);
|
||||
}
|
||||
|
||||
List<ContactGroup> _filterAndSortGroups(
|
||||
List<ContactGroup> groups,
|
||||
List<Contact> contacts,
|
||||
) {
|
||||
final query = _searchQuery.trim().toLowerCase();
|
||||
final contactsByKey = <String, Contact>{};
|
||||
for (final contact in contacts) {
|
||||
contactsByKey[contact.publicKeyHex] = contact;
|
||||
}
|
||||
|
||||
final filtered = groups
|
||||
.where((group) {
|
||||
if (query.isEmpty) return true;
|
||||
if (group.name.toLowerCase().contains(query)) return true;
|
||||
for (final key in group.memberKeys) {
|
||||
final contact = contactsByKey[key];
|
||||
if (contact != null && matchesContactQuery(contact, query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.where((group) {
|
||||
if (_typeFilter == ContactTypeFilter.all) return true;
|
||||
// Groups don't have a favorite flag, so hide them under favorites filter
|
||||
if (_typeFilter == ContactTypeFilter.favorites) return false;
|
||||
for (final key in group.memberKeys) {
|
||||
final contact = contactsByKey[key];
|
||||
if (contact != null && _matchesTypeFilter(contact)) return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.toList();
|
||||
|
||||
filtered.sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
List<Contact> _filterAndSortContacts(
|
||||
List<Contact> contacts,
|
||||
MeshCoreConnector connector,
|
||||
UiViewStateService viewState,
|
||||
) {
|
||||
var filtered = contacts.where((contact) {
|
||||
if (_searchQuery.isEmpty) return true;
|
||||
return matchesContactQuery(contact, _searchQuery);
|
||||
if (viewState.contactsSearchText.isEmpty) return true;
|
||||
return matchesContactQuery(contact, viewState.contactsSearchText);
|
||||
}).toList();
|
||||
|
||||
final selectedGroup = _selectedGroupForName(
|
||||
viewState.contactsSelectedGroupName,
|
||||
);
|
||||
if (selectedGroup != null) {
|
||||
final memberKeys = selectedGroup.memberKeys.toSet();
|
||||
filtered = filtered
|
||||
.where((contact) => memberKeys.contains(contact.publicKeyHex))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filter out own node from the list
|
||||
if (connector.selfPublicKey != null) {
|
||||
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
|
||||
@@ -618,17 +858,22 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
}).toList();
|
||||
}
|
||||
|
||||
if (_typeFilter != ContactTypeFilter.all) {
|
||||
filtered = filtered.where(_matchesTypeFilter).toList();
|
||||
if (viewState.contactsTypeFilter != ContactTypeFilter.all) {
|
||||
filtered = filtered
|
||||
.where(
|
||||
(contact) =>
|
||||
_matchesTypeFilter(contact, viewState.contactsTypeFilter),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (_showUnreadOnly) {
|
||||
if (viewState.contactsShowUnreadOnly) {
|
||||
filtered = filtered.where((contact) {
|
||||
return connector.getUnreadCountForContact(contact) > 0;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
switch (_sortOption) {
|
||||
switch (viewState.contactsSortOption) {
|
||||
case ContactSortOption.lastSeen:
|
||||
filtered.sort(
|
||||
(a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)),
|
||||
@@ -657,8 +902,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
return filtered;
|
||||
}
|
||||
|
||||
bool _matchesTypeFilter(Contact contact) {
|
||||
switch (_typeFilter) {
|
||||
bool _matchesTypeFilter(Contact contact, ContactTypeFilter typeFilter) {
|
||||
switch (typeFilter) {
|
||||
case ContactTypeFilter.all:
|
||||
return true;
|
||||
case ContactTypeFilter.favorites:
|
||||
@@ -679,57 +924,6 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
: contact.lastSeen;
|
||||
}
|
||||
|
||||
Widget _buildGroupTile(
|
||||
BuildContext context,
|
||||
ContactGroup group,
|
||||
List<Contact> contacts,
|
||||
) {
|
||||
final memberContacts = _resolveGroupContacts(group, contacts);
|
||||
final subtitle = _formatGroupMembers(context, memberContacts);
|
||||
return ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: Colors.teal,
|
||||
child: Icon(Icons.group, color: Colors.white, size: 20),
|
||||
),
|
||||
title: Text(group.name),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: Text(
|
||||
memberContacts.length.toString(),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
onTap: () => _showGroupOptions(context, group, contacts),
|
||||
onLongPress: () => _showGroupOptions(context, group, contacts),
|
||||
);
|
||||
}
|
||||
|
||||
List<Contact> _resolveGroupContacts(
|
||||
ContactGroup group,
|
||||
List<Contact> contacts,
|
||||
) {
|
||||
final byKey = <String, Contact>{};
|
||||
for (final contact in contacts) {
|
||||
byKey[contact.publicKeyHex] = contact;
|
||||
}
|
||||
final resolved = <Contact>[];
|
||||
for (final key in group.memberKeys) {
|
||||
final contact = byKey[key];
|
||||
if (contact != null) {
|
||||
resolved.add(contact);
|
||||
}
|
||||
}
|
||||
resolved.sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
String _formatGroupMembers(BuildContext context, List<Contact> members) {
|
||||
if (members.isEmpty) return context.l10n.contacts_noMembers;
|
||||
final names = members.map((c) => c.name).toList();
|
||||
if (names.length <= 2) return names.join(', ');
|
||||
return '${names.take(2).join(', ')} +${names.length - 2}';
|
||||
}
|
||||
|
||||
void _openChat(BuildContext context, Contact contact) {
|
||||
// Check if this is a repeater
|
||||
if (contact.type == advTypeRepeater) {
|
||||
@@ -807,58 +1001,11 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
);
|
||||
}
|
||||
|
||||
void _showGroupOptions(
|
||||
BuildContext context,
|
||||
ContactGroup group,
|
||||
List<Contact> contacts,
|
||||
) {
|
||||
final members = _resolveGroupContacts(group, contacts);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit),
|
||||
title: Text(context.l10n.contacts_editGroup),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showGroupEditor(context, contacts, group: group);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: Colors.red),
|
||||
title: Text(
|
||||
context.l10n.contacts_deleteGroup,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_confirmDeleteGroup(context, group);
|
||||
},
|
||||
),
|
||||
if (members.isNotEmpty) const Divider(),
|
||||
...members.map((member) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.person),
|
||||
title: Text(member.name),
|
||||
subtitle: Text(member.typeLabel),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_openChat(context, member);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDeleteGroup(BuildContext context, ContactGroup group) {
|
||||
if (!_hasGroupStoreScope(context.read<MeshCoreConnector>())) {
|
||||
_showGroupsUnavailableMessage(context);
|
||||
return;
|
||||
}
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
@@ -874,6 +1021,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
Navigator.pop(dialogContext);
|
||||
setState(() {
|
||||
_groups.removeWhere((g) => g.name == group.name);
|
||||
_ensureValidSelectedGroup();
|
||||
});
|
||||
await _saveGroups();
|
||||
},
|
||||
@@ -892,6 +1040,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
List<Contact> contacts, {
|
||||
ContactGroup? group,
|
||||
}) {
|
||||
if (!_hasGroupStoreScope(context.read<MeshCoreConnector>())) {
|
||||
_showGroupsUnavailableMessage(context);
|
||||
return;
|
||||
}
|
||||
final isEditing = group != null;
|
||||
final nameController = TextEditingController(text: group?.name ?? '');
|
||||
final selectedKeys = <String>{...group?.memberKeys ?? []};
|
||||
@@ -918,64 +1070,70 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.contacts_groupName,
|
||||
border: const OutlineInputBorder(),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.contacts_groupName,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.contacts_filterContacts,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.contacts_filterContacts,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
filterQuery = value.toLowerCase();
|
||||
});
|
||||
},
|
||||
),
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
filterQuery = value.toLowerCase();
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: filteredContacts.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
context.l10n.contacts_noContactsMatchFilter,
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: filteredContacts.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
context.l10n.contacts_noContactsMatchFilter,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: filteredContacts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = filteredContacts[index];
|
||||
final isSelected = selectedKeys.contains(
|
||||
contact.publicKeyHex,
|
||||
);
|
||||
return CheckboxListTile(
|
||||
value: isSelected,
|
||||
title: Text(contact.name),
|
||||
subtitle: Text(contact.typeLabel),
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
if (value == true) {
|
||||
selectedKeys.add(contact.publicKeyHex);
|
||||
} else {
|
||||
selectedKeys.remove(
|
||||
contact.publicKeyHex,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: filteredContacts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = filteredContacts[index];
|
||||
final isSelected = selectedKeys.contains(
|
||||
contact.publicKeyHex,
|
||||
);
|
||||
return CheckboxListTile(
|
||||
value: isSelected,
|
||||
title: Text(contact.name),
|
||||
subtitle: Text(contact.typeLabel),
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
if (value == true) {
|
||||
selectedKeys.add(contact.publicKeyHex);
|
||||
} else {
|
||||
selectedKeys.remove(contact.publicKeyHex);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
@@ -994,6 +1152,15 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (name.toLowerCase() ==
|
||||
contactsAllGroupsValue.toLowerCase()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.contacts_groupNameReserved),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final exists = _groups.any((g) {
|
||||
if (isEditing && g.name == group.name) return false;
|
||||
return g.name.toLowerCase() == name.toLowerCase();
|
||||
@@ -1009,15 +1176,21 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
final viewState = context.read<UiViewStateService>();
|
||||
if (isEditing) {
|
||||
final index = _groups.indexWhere(
|
||||
(g) => g.name == group.name,
|
||||
);
|
||||
if (index != -1) {
|
||||
final wasSelected =
|
||||
viewState.contactsSelectedGroupName == group.name;
|
||||
_groups[index] = ContactGroup(
|
||||
name: name,
|
||||
memberKeys: selectedKeys.toList(),
|
||||
);
|
||||
if (wasSelected) {
|
||||
viewState.setContactsSelectedGroupName(name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_groups.add(
|
||||
@@ -1026,7 +1199,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
memberKeys: selectedKeys.toList(),
|
||||
),
|
||||
);
|
||||
viewState.setContactsSelectedGroupName(name);
|
||||
}
|
||||
_ensureValidSelectedGroup();
|
||||
});
|
||||
await _saveGroups();
|
||||
if (dialogContext.mounted) {
|
||||
@@ -1064,7 +1239,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
if (isRepeater) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: contact.pathLength > 0
|
||||
title: contact.pathBytesForDisplay.isNotEmpty
|
||||
? Text(context.l10n.contacts_pathTrace)
|
||||
: Text(context.l10n.contacts_ping),
|
||||
onTap: () {
|
||||
@@ -1072,10 +1247,12 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: contact.pathLength > 0
|
||||
title: contact.pathBytesForDisplay.isNotEmpty
|
||||
? context.l10n.contacts_repeaterPathTrace
|
||||
: context.l10n.contacts_repeaterPing,
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
path: contact.pathBytesForDisplay,
|
||||
flipPathAround: true,
|
||||
targetContact: contact,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1100,10 +1277,12 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: contact.pathLength > 0
|
||||
title: contact.pathBytesForDisplay.isNotEmpty
|
||||
? context.l10n.contacts_roomPathTrace
|
||||
: context.l10n.contacts_roomPing,
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
path: contact.pathBytesForDisplay,
|
||||
flipPathAround: contact.pathBytesForDisplay.isNotEmpty,
|
||||
targetContact: contact,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1145,7 +1324,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
title: context.l10n.contacts_pathTraceTo(
|
||||
contact.name,
|
||||
),
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
path: contact.pathBytesForDisplay,
|
||||
flipPathAround: true,
|
||||
targetContact: contact,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -137,10 +137,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
builder: (context, connector, settingsService, pathHistory, child) {
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final settings = settingsService.settings;
|
||||
final allContacts = <Contact>[
|
||||
...connector.contacts,
|
||||
...connector.discoveredContacts.where((c) => !c.isActive),
|
||||
];
|
||||
final allContacts = connector.allContacts;
|
||||
|
||||
final contacts = settings.mapShowDiscoveryContacts
|
||||
? allContacts
|
||||
@@ -179,20 +176,13 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
// Filter by location
|
||||
final contactsWithLocation = filteredByKeyPrefix.where((c) {
|
||||
if (!c.hasLocation) {
|
||||
return false;
|
||||
}
|
||||
return _checkLocationPlausibility(c.latitude!, c.longitude!);
|
||||
return c.hasLocation;
|
||||
}).toList();
|
||||
|
||||
// All contacts with a known location — used as anchors regardless of
|
||||
// time/key-prefix filters so that repeaters are always available.
|
||||
final allContactsWithLocation = allContacts
|
||||
.where(
|
||||
(c) =>
|
||||
c.hasLocation &&
|
||||
_checkLocationPlausibility(c.latitude!, c.longitude!),
|
||||
)
|
||||
.where((c) => c.hasLocation)
|
||||
.toList();
|
||||
|
||||
// Compute guessed locations with caching
|
||||
|
||||
@@ -124,10 +124,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
||||
|
||||
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
|
||||
final buffer = BufferReader(frame);
|
||||
final contacts = <Contact>[
|
||||
...connector.contacts,
|
||||
...connector.discoveredContacts,
|
||||
];
|
||||
final contacts = connector.allContacts;
|
||||
try {
|
||||
final neighborCount = buffer.readUInt16LE();
|
||||
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
|
||||
|
||||
@@ -52,8 +52,8 @@ class PathTraceMapScreen extends StatefulWidget {
|
||||
final String title;
|
||||
final Uint8List path;
|
||||
final int? repeaterId;
|
||||
final bool flipPathRound;
|
||||
final bool reversePathRound;
|
||||
final bool flipPathAround;
|
||||
final bool reversePathAround;
|
||||
final Contact? targetContact;
|
||||
|
||||
const PathTraceMapScreen({
|
||||
@@ -61,8 +61,8 @@ class PathTraceMapScreen extends StatefulWidget {
|
||||
required this.title,
|
||||
required this.path,
|
||||
this.repeaterId,
|
||||
this.flipPathRound = false,
|
||||
this.reversePathRound = false,
|
||||
this.flipPathAround = false,
|
||||
this.reversePathAround = false,
|
||||
this.targetContact,
|
||||
});
|
||||
|
||||
@@ -93,6 +93,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
ValueKey<String> _mapKey = const ValueKey('initial');
|
||||
double _pathDistanceMeters = 0.0;
|
||||
bool _showNodeLabels = true;
|
||||
Contact? _targetContact;
|
||||
|
||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||
return pathBytes
|
||||
@@ -158,21 +159,16 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
final Uint8List path;
|
||||
|
||||
Uint8List pathTmp = widget.reversePathRound
|
||||
final pathTmp = widget.reversePathAround
|
||||
? Uint8List.fromList(widget.path.reversed.toList())
|
||||
: widget.path;
|
||||
|
||||
if (widget.flipPathRound) {
|
||||
path = buildPath(pathTmp);
|
||||
} else {
|
||||
path = pathTmp;
|
||||
}
|
||||
final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp;
|
||||
|
||||
appLogger.info(
|
||||
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
|
||||
tag: 'PathTraceMapScreen',
|
||||
noNotify: !mounted,
|
||||
);
|
||||
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
@@ -263,10 +259,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
.toList();
|
||||
|
||||
Map<int, Contact> pathContacts = {};
|
||||
final contacts = <Contact>[
|
||||
...connector.contacts,
|
||||
...connector.discoveredContacts,
|
||||
];
|
||||
final contacts = connector.allContacts;
|
||||
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||
for (var repeaterData in pathData) {
|
||||
if (listEquals(
|
||||
@@ -312,18 +305,21 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
// Compute endpoint position for the target contact.
|
||||
LatLng? targetPos;
|
||||
bool targetGuessed = false;
|
||||
final target = widget.targetContact;
|
||||
if (target != null) {
|
||||
if (target.hasLocation) {
|
||||
targetPos = LatLng(target.latitude!, target.longitude!);
|
||||
} else if (pathData.isNotEmpty) {
|
||||
_targetContact = widget.targetContact;
|
||||
|
||||
if (_targetContact != null) {
|
||||
final tc = _targetContact!;
|
||||
if (tc.hasLocation) {
|
||||
targetPos = LatLng(tc.latitude!, tc.longitude!);
|
||||
} else if (widget.path.length > 1) {
|
||||
// Infer from the last hop: average GPS contacts sharing that hop.
|
||||
// For a round-trip path (flipPathRound), the target-side hop sits
|
||||
// in the middle of the symmetric sequence; .last is the local side.
|
||||
final lastHop = (widget.flipPathRound && pathData.length > 1)
|
||||
? pathData[(pathData.length - 1) ~/ 2]
|
||||
: pathData.last;
|
||||
final peers = connector.contacts
|
||||
// For a round-trip path (flipPathAround/reversePathAround), the target-side hop
|
||||
// sits in the middle of the symmetric sequence; .last is the local side.
|
||||
final lastHop = widget.reversePathAround
|
||||
? widget.path.first
|
||||
: widget.path.last;
|
||||
|
||||
final peers = connector.allContacts
|
||||
.where(
|
||||
(c) =>
|
||||
c.hasLocation &&
|
||||
@@ -339,12 +335,34 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
|
||||
peers.length;
|
||||
const offsetDeg = 0.003;
|
||||
final angle = (target.publicKey[1] / 255.0) * 2 * pi;
|
||||
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
||||
targetPos = LatLng(
|
||||
lat + offsetDeg * cos(angle),
|
||||
lon + offsetDeg * sin(angle),
|
||||
);
|
||||
targetGuessed = true;
|
||||
} else if (inferredPositions.containsKey(lastHop)) {
|
||||
final lat = inferredPositions[lastHop]!.latitude;
|
||||
final lon = inferredPositions[lastHop]!.longitude;
|
||||
const offsetDeg = 0.003;
|
||||
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
||||
targetPos = LatLng(
|
||||
lat + offsetDeg * cos(angle),
|
||||
lon + offsetDeg * sin(angle),
|
||||
);
|
||||
targetGuessed = true;
|
||||
} else {
|
||||
// As a last resort, just place it at the same position as the last hop.
|
||||
final contact = pathContacts[lastHop];
|
||||
if (contact != null && contact.hasLocation) {
|
||||
const offsetDeg = 0.003;
|
||||
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
||||
targetPos = LatLng(
|
||||
contact.latitude! + offsetDeg * cos(angle),
|
||||
contact.longitude! + offsetDeg * sin(angle),
|
||||
);
|
||||
targetGuessed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,7 +371,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
|
||||
_points = <LatLng>[];
|
||||
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
||||
int hopLast = 0;
|
||||
int hopLastLast = 0;
|
||||
for (final hop in _traceData!.pathData) {
|
||||
if (hop == hopLastLast && widget.flipPathAround) {
|
||||
break; //skip duplicate hops in round-trip paths
|
||||
}
|
||||
final contact = _traceData!.pathContacts[hop];
|
||||
if (contact != null && contact.hasLocation) {
|
||||
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
||||
@@ -361,8 +384,14 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
final inferred = inferredPositions[hop];
|
||||
if (inferred != null) _points.add(inferred);
|
||||
}
|
||||
hopLastLast = hopLast;
|
||||
hopLast = hop;
|
||||
}
|
||||
if (targetPos != null) {
|
||||
if (_targetContact != null && _targetContact!.type == advTypeChat) {
|
||||
_points.add(targetPos);
|
||||
}
|
||||
}
|
||||
if (targetPos != null) _points.add(targetPos);
|
||||
_polylines = _points.length > 1
|
||||
? [
|
||||
Polyline(
|
||||
@@ -451,7 +480,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_hasData) _buildMapPathTrace(context, tileCache),
|
||||
if (_hasData)
|
||||
_buildMapPathTrace(context, tileCache, _targetContact),
|
||||
if (_points.isEmpty &&
|
||||
!_hasData &&
|
||||
!_isLoading &&
|
||||
@@ -480,17 +510,28 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
List<Marker> _buildHopMarkers(
|
||||
List<int> pathData, {
|
||||
required bool showLabels,
|
||||
required Contact? target,
|
||||
}) {
|
||||
final markers = <Marker>[];
|
||||
int hopLast = 0;
|
||||
int hopLastLast = 0;
|
||||
for (final hop in pathData) {
|
||||
final contact = _traceData!.pathContacts[hop];
|
||||
final inferred = _inferredHopPositions[hop];
|
||||
final hasGps = contact != null && contact.hasLocation;
|
||||
if (!hasGps && inferred == null) continue;
|
||||
if (hop == hopLastLast && widget.flipPathAround) {
|
||||
continue; //skip duplicate hops in round-trip paths
|
||||
}
|
||||
if (!hasGps && inferred == null) {
|
||||
hopLastLast = hopLast;
|
||||
hopLast = hop;
|
||||
continue; //skip hops with no GPS and no inferred position
|
||||
}
|
||||
final point = hasGps
|
||||
? LatLng(contact.latitude!, contact.longitude!)
|
||||
: inferred!;
|
||||
final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||
|
||||
markers.add(
|
||||
Marker(
|
||||
point: point,
|
||||
@@ -532,6 +573,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
hopLastLast = hopLast;
|
||||
hopLast = hop;
|
||||
}
|
||||
|
||||
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
|
||||
@@ -581,9 +624,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
|
||||
// Add target contact endpoint marker.
|
||||
final targetPos = _targetContactPosition;
|
||||
if (targetPos != null) {
|
||||
if (targetPos != null && target != null && target.type == advTypeChat) {
|
||||
final isGuessed = _targetContactIsGuessed;
|
||||
final targetName = widget.targetContact?.name ?? '?';
|
||||
final targetName = target.name;
|
||||
markers.add(
|
||||
Marker(
|
||||
point: targetPos,
|
||||
@@ -719,6 +762,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
Widget _buildMapPathTrace(
|
||||
BuildContext context,
|
||||
MapTileCacheService tileCache,
|
||||
Contact? target,
|
||||
) {
|
||||
return FlutterMap(
|
||||
key: _mapKey,
|
||||
@@ -757,6 +801,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
markers: _buildHopMarkers(
|
||||
_traceData!.pathData,
|
||||
showLabels: _showNodeLabels,
|
||||
target: target,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import 'contacts_screen.dart';
|
||||
@@ -27,8 +28,14 @@ class _TcpScreenState extends State<TcpScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_hostController = TextEditingController();
|
||||
_portController = TextEditingController(text: '5000');
|
||||
_hostController = TextEditingController(
|
||||
text: context.read<AppSettingsService>().settings.tcpServerAddress,
|
||||
);
|
||||
_portController = TextEditingController(
|
||||
text: context.read<AppSettingsService>().settings.tcpServerPort > 0
|
||||
? context.read<AppSettingsService>().settings.tcpServerPort.toString()
|
||||
: '',
|
||||
);
|
||||
_connector = context.read<MeshCoreConnector>();
|
||||
|
||||
_connectionListener = () {
|
||||
@@ -39,6 +46,12 @@ class _TcpScreenState extends State<TcpScreen> {
|
||||
if (_connector.state == MeshCoreConnectionState.connected &&
|
||||
_connector.isTcpTransportConnected &&
|
||||
!_navigatedToContacts) {
|
||||
context.read<AppSettingsService>().setTcpServerAddress(
|
||||
_hostController.text,
|
||||
);
|
||||
context.read<AppSettingsService>().setTcpServerPort(
|
||||
int.tryParse(_portController.text) ?? 0,
|
||||
);
|
||||
_navigatedToContacts = true;
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||
|
||||
@@ -51,6 +51,7 @@ class AppDebugLogService extends ChangeNotifier {
|
||||
String message, {
|
||||
String tag = 'App',
|
||||
AppDebugLogLevel level = AppDebugLogLevel.info,
|
||||
bool noNotify = false,
|
||||
}) {
|
||||
if (!_enabled && !kDebugMode) return;
|
||||
if (!_enabled) {
|
||||
@@ -72,22 +73,24 @@ class AppDebugLogService extends ChangeNotifier {
|
||||
_entries.removeRange(0, _entries.length - maxEntries);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
if (!noNotify) {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Also print to console for development
|
||||
debugPrint('[$tag] $message');
|
||||
}
|
||||
|
||||
void info(String message, {String tag = 'App'}) {
|
||||
log(message, tag: tag, level: AppDebugLogLevel.info);
|
||||
void info(String message, {String tag = 'App', bool noNotify = false}) {
|
||||
log(message, tag: tag, level: AppDebugLogLevel.info, noNotify: noNotify);
|
||||
}
|
||||
|
||||
void warn(String message, {String tag = 'App'}) {
|
||||
log(message, tag: tag, level: AppDebugLogLevel.warning);
|
||||
void warn(String message, {String tag = 'App', bool noNotify = false}) {
|
||||
log(message, tag: tag, level: AppDebugLogLevel.warning, noNotify: noNotify);
|
||||
}
|
||||
|
||||
void error(String message, {String tag = 'App'}) {
|
||||
log(message, tag: tag, level: AppDebugLogLevel.error);
|
||||
void error(String message, {String tag = 'App', bool noNotify = false}) {
|
||||
log(message, tag: tag, level: AppDebugLogLevel.error, noNotify: noNotify);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
|
||||
@@ -182,4 +182,12 @@ class AppSettingsService extends ChangeNotifier {
|
||||
..remove(channelName);
|
||||
await updateSettings(_settings.copyWith(mutedChannels: updated));
|
||||
}
|
||||
|
||||
Future<void> setTcpServerAddress(String value) async {
|
||||
await updateSettings(_settings.copyWith(tcpServerAddress: value));
|
||||
}
|
||||
|
||||
Future<void> setTcpServerPort(int value) async {
|
||||
await updateSettings(_settings.copyWith(tcpServerPort: value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ class ChatTextScaleService extends ChangeNotifier {
|
||||
|
||||
void _commitScale() {
|
||||
_saveTimer?.cancel();
|
||||
PrefsManager.instance.setDouble(_prefKey, _scale);
|
||||
unawaited(PrefsManager.instance.setDouble(_prefKey, _scale));
|
||||
}
|
||||
|
||||
double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble();
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../storage/prefs_manager.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
|
||||
const String contactsAllGroupsValue = '__all__';
|
||||
|
||||
enum ChannelSortOption { manual, name, latestMessages, unread }
|
||||
|
||||
class UiViewStateService extends ChangeNotifier {
|
||||
static const _keyContactsSelectedGroupName = 'ui_contacts_selected_group';
|
||||
static const _keyContactsSortOption = 'ui_contacts_sort_option';
|
||||
static const _keyContactsShowUnreadOnly = 'ui_contacts_show_unread_only';
|
||||
static const _keyContactsTypeFilter = 'ui_contacts_type_filter';
|
||||
static const _keyChannelsSortOption = 'ui_channels_sort_option';
|
||||
static const _keyChannelsSortIndexLegacy = 'ui_channels_sort_index';
|
||||
|
||||
String _contactsSelectedGroupName = contactsAllGroupsValue;
|
||||
String _contactsSearchText = '';
|
||||
bool _contactsSearchExpanded = false;
|
||||
ContactSortOption _contactsSortOption = ContactSortOption.lastSeen;
|
||||
bool _contactsShowUnreadOnly = false;
|
||||
ContactTypeFilter _contactsTypeFilter = ContactTypeFilter.all;
|
||||
|
||||
String _channelsSearchText = '';
|
||||
ChannelSortOption _channelsSortOption = ChannelSortOption.manual;
|
||||
|
||||
String get contactsSelectedGroupName => _contactsSelectedGroupName;
|
||||
String get contactsSearchText => _contactsSearchText;
|
||||
bool get contactsSearchExpanded => _contactsSearchExpanded;
|
||||
ContactSortOption get contactsSortOption => _contactsSortOption;
|
||||
bool get contactsShowUnreadOnly => _contactsShowUnreadOnly;
|
||||
ContactTypeFilter get contactsTypeFilter => _contactsTypeFilter;
|
||||
String get channelsSearchText => _channelsSearchText;
|
||||
ChannelSortOption get channelsSortOption => _channelsSortOption;
|
||||
|
||||
Future<void> initialize() async {
|
||||
final prefs = PrefsManager.instance;
|
||||
|
||||
final selectedGroupName = prefs.getString(_keyContactsSelectedGroupName);
|
||||
if (selectedGroupName != null && selectedGroupName.isNotEmpty) {
|
||||
_contactsSelectedGroupName = selectedGroupName;
|
||||
}
|
||||
|
||||
final sortStr = prefs.getString(_keyContactsSortOption);
|
||||
if (sortStr != null) {
|
||||
_contactsSortOption = ContactSortOption.values.firstWhere(
|
||||
(e) => e.name == sortStr,
|
||||
orElse: () => ContactSortOption.lastSeen,
|
||||
);
|
||||
}
|
||||
|
||||
_contactsShowUnreadOnly =
|
||||
prefs.getBool(_keyContactsShowUnreadOnly) ?? false;
|
||||
|
||||
final typeStr = prefs.getString(_keyContactsTypeFilter);
|
||||
if (typeStr != null) {
|
||||
_contactsTypeFilter = ContactTypeFilter.values.firstWhere(
|
||||
(e) => e.name == typeStr,
|
||||
orElse: () => ContactTypeFilter.all,
|
||||
);
|
||||
}
|
||||
|
||||
final channelSortStr = prefs.getString(_keyChannelsSortOption);
|
||||
if (channelSortStr != null) {
|
||||
_channelsSortOption = ChannelSortOption.values.firstWhere(
|
||||
(e) => e.name == channelSortStr,
|
||||
orElse: () => ChannelSortOption.manual,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Backward compatibility for old persisted index format.
|
||||
switch (prefs.getInt(_keyChannelsSortIndexLegacy) ?? 0) {
|
||||
case 0:
|
||||
_channelsSortOption = ChannelSortOption.manual;
|
||||
break;
|
||||
case 1:
|
||||
_channelsSortOption = ChannelSortOption.name;
|
||||
break;
|
||||
case 2:
|
||||
_channelsSortOption = ChannelSortOption.latestMessages;
|
||||
break;
|
||||
case 3:
|
||||
_channelsSortOption = ChannelSortOption.unread;
|
||||
break;
|
||||
default:
|
||||
_channelsSortOption = ChannelSortOption.manual;
|
||||
}
|
||||
}
|
||||
|
||||
void setContactsSelectedGroupName(String value) {
|
||||
if (_contactsSelectedGroupName == value) return;
|
||||
_contactsSelectedGroupName = value;
|
||||
notifyListeners();
|
||||
unawaited(
|
||||
PrefsManager.instance.setString(_keyContactsSelectedGroupName, value),
|
||||
);
|
||||
}
|
||||
|
||||
void setContactsSearchText(String value) {
|
||||
if (_contactsSearchText == value) return;
|
||||
_contactsSearchText = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setContactsSearchExpanded(bool value) {
|
||||
if (_contactsSearchExpanded == value) return;
|
||||
_contactsSearchExpanded = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setContactsSortOption(ContactSortOption value) {
|
||||
if (_contactsSortOption == value) return;
|
||||
_contactsSortOption = value;
|
||||
notifyListeners();
|
||||
unawaited(
|
||||
PrefsManager.instance.setString(_keyContactsSortOption, value.name),
|
||||
);
|
||||
}
|
||||
|
||||
void setContactsShowUnreadOnly(bool value) {
|
||||
if (_contactsShowUnreadOnly == value) return;
|
||||
_contactsShowUnreadOnly = value;
|
||||
notifyListeners();
|
||||
unawaited(PrefsManager.instance.setBool(_keyContactsShowUnreadOnly, value));
|
||||
}
|
||||
|
||||
void setContactsTypeFilter(ContactTypeFilter value) {
|
||||
if (_contactsTypeFilter == value) return;
|
||||
_contactsTypeFilter = value;
|
||||
notifyListeners();
|
||||
unawaited(
|
||||
PrefsManager.instance.setString(_keyContactsTypeFilter, value.name),
|
||||
);
|
||||
}
|
||||
|
||||
void setChannelsSearchText(String value) {
|
||||
if (_channelsSearchText == value) return;
|
||||
_channelsSearchText = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setChannelsSortOption(ChannelSortOption value) {
|
||||
if (_channelsSortOption == value) return;
|
||||
_channelsSortOption = value;
|
||||
notifyListeners();
|
||||
unawaited(
|
||||
PrefsManager.instance.setString(_keyChannelsSortOption, value.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,10 @@ class UsbSerialService {
|
||||
serial.setStopBits1();
|
||||
serial.setFlowControlNone();
|
||||
serial.setRTS(false);
|
||||
// Toggle DTR low→high so the device sees a fresh connection even
|
||||
// if the previous disconnect didn't cleanly signal DTR drop.
|
||||
serial.setDTR(false);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
serial.setDTR(true);
|
||||
_serial = serial;
|
||||
// Update the normalized port name to whichever candidate succeeded.
|
||||
@@ -249,6 +253,21 @@ class UsbSerialService {
|
||||
_status = UsbSerialStatus.connected;
|
||||
}
|
||||
|
||||
Future<void> writeRaw(Uint8List data) async {
|
||||
if (!isConnected) {
|
||||
throw StateError('USB serial port is not open');
|
||||
}
|
||||
if (_useAndroidUsbHost) {
|
||||
try {
|
||||
await _androidMethodChannel.invokeMethod<void>('write', {'data': data});
|
||||
} on PlatformException catch (error) {
|
||||
throw StateError(error.message ?? error.code);
|
||||
}
|
||||
} else {
|
||||
_serial!.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> write(Uint8List data) async {
|
||||
if (!isConnected) {
|
||||
throw StateError('USB serial port is not open');
|
||||
@@ -300,6 +319,7 @@ class UsbSerialService {
|
||||
_serial = null;
|
||||
try {
|
||||
if (serial?.isOpen() == FlOpenStatus.open) {
|
||||
serial?.setDTR(false);
|
||||
serial?.closePort();
|
||||
}
|
||||
} catch (_) {
|
||||
@@ -350,6 +370,7 @@ class UsbSerialService {
|
||||
final serial = _serial;
|
||||
try {
|
||||
if (serial?.isOpen() == FlOpenStatus.open) {
|
||||
serial?.setDTR(false);
|
||||
serial?.closePort(); // synchronous C call — kills the SerialThread
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
@@ -127,6 +127,17 @@ class UsbSerialService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> writeRaw(Uint8List data) async {
|
||||
if (!isConnected || _writer == null) {
|
||||
throw StateError('USB serial port is not open');
|
||||
}
|
||||
final promise = _writer!.callMethod<JSPromise<JSAny?>>(
|
||||
'write'.toJS,
|
||||
data.toJS,
|
||||
);
|
||||
await promise.toDart;
|
||||
}
|
||||
|
||||
Future<void> write(Uint8List data) async {
|
||||
if (!isConnected || _writer == null) {
|
||||
throw StateError('USB serial port is not open');
|
||||
|
||||
@@ -23,23 +23,23 @@ class AppLogger {
|
||||
bool get isEnabled => _enabled;
|
||||
|
||||
/// Log an info message
|
||||
void info(String message, {String tag = 'App'}) {
|
||||
void info(String message, {String tag = 'App', bool noNotify = false}) {
|
||||
if (_enabled && _service != null) {
|
||||
_service!.info(message, tag: tag);
|
||||
_service!.info(message, tag: tag, noNotify: noNotify);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a warning message
|
||||
void warn(String message, {String tag = 'App'}) {
|
||||
void warn(String message, {String tag = 'App', bool noNotify = false}) {
|
||||
if (_enabled && _service != null) {
|
||||
_service!.warn(message, tag: tag);
|
||||
_service!.warn(message, tag: tag, noNotify: noNotify);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log an error message
|
||||
void error(String message, {String tag = 'App'}) {
|
||||
void error(String message, {String tag = 'App', bool noNotify = false}) {
|
||||
if (_enabled && _service != null) {
|
||||
_service!.error(message, tag: tag);
|
||||
_service!.error(message, tag: tag, noNotify: noNotify);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +48,10 @@ class AppLogger {
|
||||
String message, {
|
||||
String tag = 'App',
|
||||
AppDebugLogLevel level = AppDebugLogLevel.info,
|
||||
bool noNotify = false,
|
||||
}) {
|
||||
if (_enabled && _service != null) {
|
||||
_service!.log(message, tag: tag, level: level);
|
||||
_service!.log(message, tag: tag, level: level, noNotify: noNotify);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
enum ContactSortOption { lastSeen, recentMessages, name }
|
||||
|
||||
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
|
||||
@@ -1,5 +1,7 @@
|
||||
import '../models/contact.dart';
|
||||
|
||||
export 'contact_filter_types.dart';
|
||||
|
||||
bool matchesContactQuery(Contact contact, String query) {
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
if (normalizedQuery.isEmpty) return true;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
|
||||
enum ContactSortOption { lastSeen, recentMessages, name }
|
||||
|
||||
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
|
||||
|
||||
class SortFilterMenuOption {
|
||||
final int value;
|
||||
class SortFilterMenuOption<T> {
|
||||
final T value;
|
||||
final String label;
|
||||
final bool? checked;
|
||||
|
||||
@@ -17,16 +14,16 @@ class SortFilterMenuOption {
|
||||
});
|
||||
}
|
||||
|
||||
class SortFilterMenuSection {
|
||||
class SortFilterMenuSection<T> {
|
||||
final String title;
|
||||
final List<SortFilterMenuOption> options;
|
||||
final List<SortFilterMenuOption<T>> options;
|
||||
|
||||
const SortFilterMenuSection({required this.title, required this.options});
|
||||
}
|
||||
|
||||
class SortFilterMenu extends StatelessWidget {
|
||||
final List<SortFilterMenuSection> sections;
|
||||
final ValueChanged<int> onSelected;
|
||||
class SortFilterMenu<T> extends StatelessWidget {
|
||||
final List<SortFilterMenuSection<T>> sections;
|
||||
final ValueChanged<T> onSelected;
|
||||
final String tooltip;
|
||||
final Widget icon;
|
||||
|
||||
@@ -40,7 +37,7 @@ class SortFilterMenu extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton<int>(
|
||||
return PopupMenuButton<T>(
|
||||
icon: icon,
|
||||
tooltip: tooltip,
|
||||
onSelected: onSelected,
|
||||
@@ -53,11 +50,11 @@ class SortFilterMenu extends StatelessWidget {
|
||||
final visibleSections = sections
|
||||
.where((section) => section.options.isNotEmpty)
|
||||
.toList();
|
||||
final entries = <PopupMenuEntry<int>>[];
|
||||
final entries = <PopupMenuEntry<T>>[];
|
||||
for (int i = 0; i < visibleSections.length; i++) {
|
||||
final section = visibleSections[i];
|
||||
entries.add(
|
||||
PopupMenuItem<int>(
|
||||
PopupMenuItem<T>(
|
||||
enabled: false,
|
||||
child: Text(section.title, style: labelStyle),
|
||||
),
|
||||
@@ -65,14 +62,14 @@ class SortFilterMenu extends StatelessWidget {
|
||||
for (final option in section.options) {
|
||||
if (option.checked == null) {
|
||||
entries.add(
|
||||
PopupMenuItem<int>(
|
||||
PopupMenuItem<T>(
|
||||
value: option.value,
|
||||
child: Text(option.label),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
entries.add(
|
||||
CheckedPopupMenuItem<int>(
|
||||
CheckedPopupMenuItem<T>(
|
||||
value: option.value,
|
||||
checked: option.checked ?? false,
|
||||
child: Text(option.label),
|
||||
@@ -90,16 +87,23 @@ class SortFilterMenu extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
const int _actionSortRecentMessages = 1;
|
||||
const int _actionSortName = 2;
|
||||
const int _actionSortLastSeen = 3;
|
||||
const int _actionFilterAll = 4;
|
||||
const int _actionFilterFavorites = 5;
|
||||
const int _actionFilterUsers = 6;
|
||||
const int _actionFilterRepeaters = 7;
|
||||
const int _actionFilterRooms = 8;
|
||||
const int _actionToggleUnreadOnly = 9;
|
||||
const int _actionNewGroup = 10;
|
||||
sealed class _ContactsFilterAction {
|
||||
const _ContactsFilterAction();
|
||||
}
|
||||
|
||||
class _SortAction extends _ContactsFilterAction {
|
||||
final ContactSortOption option;
|
||||
const _SortAction(this.option);
|
||||
}
|
||||
|
||||
class _TypeFilterAction extends _ContactsFilterAction {
|
||||
final ContactTypeFilter filter;
|
||||
const _TypeFilterAction(this.filter);
|
||||
}
|
||||
|
||||
class _ToggleUnreadAction extends _ContactsFilterAction {
|
||||
const _ToggleUnreadAction();
|
||||
}
|
||||
|
||||
class ContactsFilterMenu extends StatelessWidget {
|
||||
final ContactSortOption sortOption;
|
||||
@@ -108,7 +112,6 @@ class ContactsFilterMenu extends StatelessWidget {
|
||||
final ValueChanged<ContactSortOption> onSortChanged;
|
||||
final ValueChanged<ContactTypeFilter> onTypeFilterChanged;
|
||||
final ValueChanged<bool> onUnreadOnlyChanged;
|
||||
final VoidCallback onNewGroup;
|
||||
|
||||
const ContactsFilterMenu({
|
||||
super.key,
|
||||
@@ -118,30 +121,29 @@ class ContactsFilterMenu extends StatelessWidget {
|
||||
required this.onSortChanged,
|
||||
required this.onTypeFilterChanged,
|
||||
required this.onUnreadOnlyChanged,
|
||||
required this.onNewGroup,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return SortFilterMenu(
|
||||
return SortFilterMenu<_ContactsFilterAction>(
|
||||
tooltip: l10n.listFilter_tooltip,
|
||||
sections: [
|
||||
SortFilterMenuSection(
|
||||
title: l10n.listFilter_sortBy,
|
||||
options: [
|
||||
SortFilterMenuOption(
|
||||
value: _actionSortRecentMessages,
|
||||
value: _SortAction(ContactSortOption.recentMessages),
|
||||
label: l10n.listFilter_latestMessages,
|
||||
checked: sortOption == ContactSortOption.recentMessages,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionSortLastSeen,
|
||||
value: _SortAction(ContactSortOption.lastSeen),
|
||||
label: l10n.listFilter_heardRecently,
|
||||
checked: sortOption == ContactSortOption.lastSeen,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionSortName,
|
||||
value: _SortAction(ContactSortOption.name),
|
||||
label: l10n.listFilter_az,
|
||||
checked: sortOption == ContactSortOption.name,
|
||||
),
|
||||
@@ -151,80 +153,66 @@ class ContactsFilterMenu extends StatelessWidget {
|
||||
title: l10n.listFilter_filters,
|
||||
options: [
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterAll,
|
||||
value: _TypeFilterAction(ContactTypeFilter.all),
|
||||
label: l10n.listFilter_all,
|
||||
checked: typeFilter == ContactTypeFilter.all,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterFavorites,
|
||||
value: _TypeFilterAction(ContactTypeFilter.favorites),
|
||||
label: l10n.listFilter_favorites,
|
||||
checked: typeFilter == ContactTypeFilter.favorites,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterUsers,
|
||||
value: _TypeFilterAction(ContactTypeFilter.users),
|
||||
label: l10n.listFilter_users,
|
||||
checked: typeFilter == ContactTypeFilter.users,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterRepeaters,
|
||||
value: _TypeFilterAction(ContactTypeFilter.repeaters),
|
||||
label: l10n.listFilter_repeaters,
|
||||
checked: typeFilter == ContactTypeFilter.repeaters,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterRooms,
|
||||
value: _TypeFilterAction(ContactTypeFilter.rooms),
|
||||
label: l10n.listFilter_roomServers,
|
||||
checked: typeFilter == ContactTypeFilter.rooms,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionToggleUnreadOnly,
|
||||
value: const _ToggleUnreadAction(),
|
||||
label: l10n.listFilter_unreadOnly,
|
||||
checked: showUnreadOnly,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionNewGroup,
|
||||
label: l10n.listFilter_newGroup,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _actionSortRecentMessages:
|
||||
onSortChanged(ContactSortOption.recentMessages);
|
||||
break;
|
||||
case _actionSortName:
|
||||
onSortChanged(ContactSortOption.name);
|
||||
break;
|
||||
case _actionSortLastSeen:
|
||||
onSortChanged(ContactSortOption.lastSeen);
|
||||
break;
|
||||
case _actionFilterAll:
|
||||
onTypeFilterChanged(ContactTypeFilter.all);
|
||||
break;
|
||||
case _actionFilterUsers:
|
||||
onTypeFilterChanged(ContactTypeFilter.users);
|
||||
break;
|
||||
case _actionFilterFavorites:
|
||||
onTypeFilterChanged(ContactTypeFilter.favorites);
|
||||
break;
|
||||
case _actionFilterRepeaters:
|
||||
onTypeFilterChanged(ContactTypeFilter.repeaters);
|
||||
break;
|
||||
case _actionFilterRooms:
|
||||
onTypeFilterChanged(ContactTypeFilter.rooms);
|
||||
break;
|
||||
case _actionToggleUnreadOnly:
|
||||
case _SortAction(:final option):
|
||||
onSortChanged(option);
|
||||
case _TypeFilterAction(:final filter):
|
||||
onTypeFilterChanged(filter);
|
||||
case _ToggleUnreadAction():
|
||||
onUnreadOnlyChanged(!showUnreadOnly);
|
||||
break;
|
||||
case _actionNewGroup:
|
||||
onNewGroup();
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
sealed class _DiscoveryFilterAction {
|
||||
const _DiscoveryFilterAction();
|
||||
}
|
||||
|
||||
class _DiscoverySortAction extends _DiscoveryFilterAction {
|
||||
final ContactSortOption option;
|
||||
const _DiscoverySortAction(this.option);
|
||||
}
|
||||
|
||||
class _DiscoveryTypeFilterAction extends _DiscoveryFilterAction {
|
||||
final ContactTypeFilter filter;
|
||||
const _DiscoveryTypeFilterAction(this.filter);
|
||||
}
|
||||
|
||||
class DiscoveryContactsFilterMenu extends StatelessWidget {
|
||||
final ContactSortOption sortOption;
|
||||
final ContactTypeFilter typeFilter;
|
||||
@@ -242,19 +230,19 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return SortFilterMenu(
|
||||
return SortFilterMenu<_DiscoveryFilterAction>(
|
||||
tooltip: l10n.listFilter_tooltip,
|
||||
sections: [
|
||||
SortFilterMenuSection(
|
||||
title: l10n.listFilter_sortBy,
|
||||
options: [
|
||||
SortFilterMenuOption(
|
||||
value: _actionSortLastSeen,
|
||||
value: _DiscoverySortAction(ContactSortOption.lastSeen),
|
||||
label: l10n.listFilter_heardRecently,
|
||||
checked: sortOption == ContactSortOption.lastSeen,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionSortName,
|
||||
value: _DiscoverySortAction(ContactSortOption.name),
|
||||
label: l10n.listFilter_az,
|
||||
checked: sortOption == ContactSortOption.name,
|
||||
),
|
||||
@@ -264,22 +252,22 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
|
||||
title: l10n.listFilter_filters,
|
||||
options: [
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterAll,
|
||||
value: _DiscoveryTypeFilterAction(ContactTypeFilter.all),
|
||||
label: l10n.listFilter_all,
|
||||
checked: typeFilter == ContactTypeFilter.all,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterUsers,
|
||||
value: _DiscoveryTypeFilterAction(ContactTypeFilter.users),
|
||||
label: l10n.listFilter_users,
|
||||
checked: typeFilter == ContactTypeFilter.users,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterRepeaters,
|
||||
value: _DiscoveryTypeFilterAction(ContactTypeFilter.repeaters),
|
||||
label: l10n.listFilter_repeaters,
|
||||
checked: typeFilter == ContactTypeFilter.repeaters,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterRooms,
|
||||
value: _DiscoveryTypeFilterAction(ContactTypeFilter.rooms),
|
||||
label: l10n.listFilter_roomServers,
|
||||
checked: typeFilter == ContactTypeFilter.rooms,
|
||||
),
|
||||
@@ -288,27 +276,10 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
|
||||
],
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _actionSortName:
|
||||
onSortChanged(ContactSortOption.name);
|
||||
break;
|
||||
case _actionSortLastSeen:
|
||||
onSortChanged(ContactSortOption.lastSeen);
|
||||
break;
|
||||
case _actionFilterAll:
|
||||
onTypeFilterChanged(ContactTypeFilter.all);
|
||||
break;
|
||||
case _actionFilterUsers:
|
||||
onTypeFilterChanged(ContactTypeFilter.users);
|
||||
break;
|
||||
case _actionFilterFavorites:
|
||||
onTypeFilterChanged(ContactTypeFilter.favorites);
|
||||
break;
|
||||
case _actionFilterRepeaters:
|
||||
onTypeFilterChanged(ContactTypeFilter.repeaters);
|
||||
break;
|
||||
case _actionFilterRooms:
|
||||
onTypeFilterChanged(ContactTypeFilter.rooms);
|
||||
break;
|
||||
case _DiscoverySortAction(:final option):
|
||||
onSortChanged(option);
|
||||
case _DiscoveryTypeFilterAction(:final filter):
|
||||
onTypeFilterChanged(filter);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -78,7 +78,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: context.l10n.contacts_repeaterPathTrace,
|
||||
path: Uint8List.fromList(pathBytes),
|
||||
flipPathRound: true,
|
||||
flipPathAround: true,
|
||||
targetContact: widget.contact,
|
||||
),
|
||||
),
|
||||
@@ -107,7 +107,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
|
||||
}
|
||||
|
||||
final pathForInput = currentContact.pathIdList;
|
||||
final availableContacts = connector.contacts
|
||||
final availableContacts = connector.allContacts
|
||||
.where((c) => c.publicKeyHex != currentContact.publicKeyHex)
|
||||
.toList();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
|
||||
@@ -65,7 +66,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
||||
|
||||
void _filterValidContacts() {
|
||||
_validContacts = widget.availableContacts
|
||||
.where((c) => c.type == 2 || c.type == 3)
|
||||
.where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -157,10 +157,7 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
||||
repeater.snr,
|
||||
widget.connector.currentSf,
|
||||
);
|
||||
final allContacts = [
|
||||
...widget.connector.contacts,
|
||||
...widget.connector.discoveredContacts,
|
||||
];
|
||||
final allContacts = widget.connector.allContacts;
|
||||
final name = allContacts
|
||||
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
|
||||
.map((c) => c.name)
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 6.0.0+7
|
||||
version: 7.0.0+8
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
|
||||
Reference in New Issue
Block a user