mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-16 07:34:27 +10:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64d75dde45 | |||
| 9199aab7f7 | |||
| 64698e0be6 | |||
| 3dd9037be3 | |||
| 566e3aadf8 | |||
| 054a84031e | |||
| fffcff3b74 | |||
| b336aedbc5 | |||
| 2ee2358ecc | |||
| 86e9b7fe01 |
@@ -19,6 +19,7 @@ import '../services/message_retry_service.dart';
|
|||||||
import '../services/path_history_service.dart';
|
import '../services/path_history_service.dart';
|
||||||
import '../services/app_settings_service.dart';
|
import '../services/app_settings_service.dart';
|
||||||
import '../services/background_service.dart';
|
import '../services/background_service.dart';
|
||||||
|
import '../services/timeout_prediction_service.dart';
|
||||||
import '../services/notification_service.dart';
|
import '../services/notification_service.dart';
|
||||||
import 'meshcore_connector_usb.dart';
|
import 'meshcore_connector_usb.dart';
|
||||||
import 'meshcore_connector_tcp.dart';
|
import 'meshcore_connector_tcp.dart';
|
||||||
@@ -166,6 +167,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
bool _isLoadingContacts = false;
|
bool _isLoadingContacts = false;
|
||||||
bool _isLoadingChannels = false;
|
bool _isLoadingChannels = false;
|
||||||
bool _hasLoadedChannels = false;
|
bool _hasLoadedChannels = false;
|
||||||
|
TimeoutPredictionService? _timeoutPredictionService;
|
||||||
|
// Intentionally global (not per-contact): tracks overall network activity.
|
||||||
|
// Frequent RX from any source indicates a busy network with more collisions.
|
||||||
|
DateTime _lastRxTime = DateTime.now();
|
||||||
bool _batteryRequested = false;
|
bool _batteryRequested = false;
|
||||||
bool _awaitingSelfInfo = false;
|
bool _awaitingSelfInfo = false;
|
||||||
bool _hasReceivedDeviceInfo = false;
|
bool _hasReceivedDeviceInfo = false;
|
||||||
@@ -672,6 +677,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
BleDebugLogService? bleDebugLogService,
|
BleDebugLogService? bleDebugLogService,
|
||||||
AppDebugLogService? appDebugLogService,
|
AppDebugLogService? appDebugLogService,
|
||||||
BackgroundService? backgroundService,
|
BackgroundService? backgroundService,
|
||||||
|
TimeoutPredictionService? timeoutPredictionService,
|
||||||
}) {
|
}) {
|
||||||
_retryService = retryService;
|
_retryService = retryService;
|
||||||
_pathHistoryService = pathHistoryService;
|
_pathHistoryService = pathHistoryService;
|
||||||
@@ -679,6 +685,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_bleDebugLogService = bleDebugLogService;
|
_bleDebugLogService = bleDebugLogService;
|
||||||
_appDebugLogService = appDebugLogService;
|
_appDebugLogService = appDebugLogService;
|
||||||
_backgroundService = backgroundService;
|
_backgroundService = backgroundService;
|
||||||
|
_timeoutPredictionService = timeoutPredictionService;
|
||||||
_usbManager.setDebugLogService(_appDebugLogService);
|
_usbManager.setDebugLogService(_appDebugLogService);
|
||||||
_tcpConnector.setDebugLogService(_appDebugLogService);
|
_tcpConnector.setDebugLogService(_appDebugLogService);
|
||||||
|
|
||||||
@@ -693,13 +700,28 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
updateMessageCallback: _updateMessage,
|
updateMessageCallback: _updateMessage,
|
||||||
clearContactPathCallback: clearContactPath,
|
clearContactPathCallback: clearContactPath,
|
||||||
setContactPathCallback: setContactPath,
|
setContactPathCallback: setContactPath,
|
||||||
calculateTimeoutCallback: (pathLength, messageBytes) =>
|
calculateTimeoutCallback:
|
||||||
calculateTimeout(pathLength: pathLength, messageBytes: messageBytes),
|
(pathLength, messageBytes, {String? contactKey}) => calculateTimeout(
|
||||||
|
pathLength: pathLength,
|
||||||
|
messageBytes: messageBytes,
|
||||||
|
contactKey: contactKey,
|
||||||
|
),
|
||||||
getSelfPublicKeyCallback: () => _selfPublicKey,
|
getSelfPublicKeyCallback: () => _selfPublicKey,
|
||||||
prepareContactOutboundTextCallback: prepareContactOutboundText,
|
prepareContactOutboundTextCallback: prepareContactOutboundText,
|
||||||
appSettingsService: appSettingsService,
|
appSettingsService: appSettingsService,
|
||||||
debugLogService: _appDebugLogService,
|
debugLogService: _appDebugLogService,
|
||||||
recordPathResultCallback: _recordPathResult,
|
recordPathResultCallback: _recordPathResult,
|
||||||
|
onDeliveryObservedCallback:
|
||||||
|
(contactKey, pathLength, messageBytes, tripTimeMs) {
|
||||||
|
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
|
||||||
|
_timeoutPredictionService?.recordObservation(
|
||||||
|
contactKey: contactKey,
|
||||||
|
pathLength: pathLength,
|
||||||
|
messageBytes: messageBytes,
|
||||||
|
tripTimeMs: tripTimeMs,
|
||||||
|
secondsSinceLastRx: secSinceRx,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2509,6 +2531,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
void _handleFrame(List<int> data) {
|
void _handleFrame(List<int> data) {
|
||||||
if (data.isEmpty) return;
|
if (data.isEmpty) return;
|
||||||
|
_lastRxTime = DateTime.now();
|
||||||
|
|
||||||
final frame = Uint8List.fromList(data);
|
final frame = Uint8List.fromList(data);
|
||||||
_receivedFramesController.add(frame);
|
_receivedFramesController.add(frame);
|
||||||
@@ -2885,36 +2908,66 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate timeout for a message based on radio settings and path length
|
/// Estimate single-packet airtime in ms from radio settings, or a fallback.
|
||||||
/// Returns timeout in milliseconds, considering number of hops
|
int _estimateAirtimeMs(int messageBytes) {
|
||||||
int calculateTimeout({required int pathLength, int messageBytes = 100}) {
|
|
||||||
// If we have radio settings, use them for accurate calculation
|
|
||||||
if (_currentFreqHz != null &&
|
if (_currentFreqHz != null &&
|
||||||
_currentBwHz != null &&
|
_currentBwHz != null &&
|
||||||
_currentSf != null &&
|
_currentSf != null &&
|
||||||
_currentCr != null) {
|
_currentCr != null) {
|
||||||
final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4;
|
final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4;
|
||||||
return calculateMessageTimeout(
|
return calculateLoRaAirtime(
|
||||||
freqHz: _currentFreqHz!,
|
payloadBytes: messageBytes,
|
||||||
bwHz: _currentBwHz!,
|
spreadingFactor: _currentSf!,
|
||||||
sf: _currentSf!,
|
bandwidthHz: _currentBwHz!,
|
||||||
cr: cr,
|
codingRate: cr,
|
||||||
pathLength: pathLength,
|
lowDataRateOptimize: _currentSf! >= 11,
|
||||||
messageBytes: messageBytes,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return 50; // fallback: ~SF7/BW125 for 100 bytes
|
||||||
// Fallback: Conservative estimates based on typical settings
|
|
||||||
// Assume SF7, BW125, which gives ~50ms airtime for 100 bytes
|
|
||||||
const estimatedAirtime = 50;
|
|
||||||
|
|
||||||
if (pathLength < 0) {
|
|
||||||
// Flood mode: Base delay + 16× airtime
|
|
||||||
return 500 + (16 * estimatedAirtime);
|
|
||||||
} else {
|
|
||||||
// Direct path: Base delay + ((airtime×6 + 250ms)×(hops+1))
|
|
||||||
return 500 + ((estimatedAirtime * 6 + 250) * (pathLength + 1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Physics-based worst-case timeout (ceiling).
|
||||||
|
int _physicsMaxTimeout(int pathLength, int airtime) {
|
||||||
|
if (pathLength < 0) {
|
||||||
|
return 500 + (16 * airtime);
|
||||||
|
} else {
|
||||||
|
return 500 + ((airtime * 6 + 250) * (pathLength + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physics-based minimum timeout (floor): raw traversal time.
|
||||||
|
int _physicsMinTimeout(int pathLength, int airtime) {
|
||||||
|
if (pathLength < 0) {
|
||||||
|
return airtime;
|
||||||
|
} else {
|
||||||
|
return airtime * (pathLength + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate timeout for a message based on radio settings and path length.
|
||||||
|
/// Returns timeout in milliseconds, considering number of hops.
|
||||||
|
int calculateTimeout({
|
||||||
|
required int pathLength,
|
||||||
|
int messageBytes = 100,
|
||||||
|
String? contactKey,
|
||||||
|
}) {
|
||||||
|
final airtime = _estimateAirtimeMs(messageBytes);
|
||||||
|
final physicsMin = _physicsMinTimeout(pathLength, airtime);
|
||||||
|
final physicsMax = _physicsMaxTimeout(pathLength, airtime);
|
||||||
|
|
||||||
|
// Try ML-based prediction, clamped between physics bounds
|
||||||
|
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
|
||||||
|
final mlTimeout = _timeoutPredictionService?.predictTimeout(
|
||||||
|
contactKey: contactKey,
|
||||||
|
pathLength: pathLength,
|
||||||
|
messageBytes: messageBytes,
|
||||||
|
secondsSinceLastRx: secSinceRx,
|
||||||
|
);
|
||||||
|
if (mlTimeout != null) {
|
||||||
|
return mlTimeout.clamp(physicsMin, physicsMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
return physicsMax;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleContact(Uint8List frame, {bool isContact = true}) {
|
void _handleContact(Uint8List frame, {bool isContact = true}) {
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Нова група",
|
"contacts_newGroup": "Нова група",
|
||||||
"contacts_groupName": "Група",
|
"contacts_groupName": "Група",
|
||||||
"contacts_groupNameRequired": "Името на групата е задължително.",
|
"contacts_groupNameRequired": "Името на групата е задължително.",
|
||||||
|
"contacts_groupNameReserved": "Това име на група е запазено",
|
||||||
"contacts_groupAlreadyExists": "Групата \"{name}\" вече съществува.",
|
"contacts_groupAlreadyExists": "Групата \"{name}\" вече съществува.",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Neue Gruppe",
|
"contacts_newGroup": "Neue Gruppe",
|
||||||
"contacts_groupName": "Gruppenname",
|
"contacts_groupName": "Gruppenname",
|
||||||
"contacts_groupNameRequired": "Der Gruppennamen ist erforderlich.",
|
"contacts_groupNameRequired": "Der Gruppennamen ist erforderlich.",
|
||||||
|
"contacts_groupNameReserved": "Dieser Gruppenname ist reserviert",
|
||||||
"contacts_groupAlreadyExists": "Die Gruppe \"{name}\" existiert bereits.",
|
"contacts_groupAlreadyExists": "Die Gruppe \"{name}\" existiert bereits.",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -416,6 +416,7 @@
|
|||||||
"contacts_newGroup": "New Group",
|
"contacts_newGroup": "New Group",
|
||||||
"contacts_groupName": "Group name",
|
"contacts_groupName": "Group name",
|
||||||
"contacts_groupNameRequired": "Group name is required",
|
"contacts_groupNameRequired": "Group name is required",
|
||||||
|
"contacts_groupNameReserved": "This group name is reserved",
|
||||||
"contacts_groupAlreadyExists": "Group \"{name}\" already exists",
|
"contacts_groupAlreadyExists": "Group \"{name}\" already exists",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Nuevo Grupo",
|
"contacts_newGroup": "Nuevo Grupo",
|
||||||
"contacts_groupName": "Nombre del grupo",
|
"contacts_groupName": "Nombre del grupo",
|
||||||
"contacts_groupNameRequired": "El nombre del grupo es obligatorio",
|
"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": "El grupo \"{name}\" ya existe",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Nouveau Groupe",
|
"contacts_newGroup": "Nouveau Groupe",
|
||||||
"contacts_groupName": "Nom du groupe",
|
"contacts_groupName": "Nom du groupe",
|
||||||
"contacts_groupNameRequired": "Le nom du groupe est obligatoire.",
|
"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": "Le groupe \"{name}\" existe déjà.",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Nuovo Gruppo",
|
"contacts_newGroup": "Nuovo Gruppo",
|
||||||
"contacts_groupName": "Nome gruppo",
|
"contacts_groupName": "Nome gruppo",
|
||||||
"contacts_groupNameRequired": "Il nome del gruppo è obbligatorio.",
|
"contacts_groupNameRequired": "Il nome del gruppo è obbligatorio.",
|
||||||
|
"contacts_groupNameReserved": "Questo nome del gruppo è riservato",
|
||||||
"contacts_groupAlreadyExists": "Il gruppo \"{name}\" esiste già.",
|
"contacts_groupAlreadyExists": "Il gruppo \"{name}\" esiste già.",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -1714,6 +1714,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Group name is required'**
|
/// **'Group name is required'**
|
||||||
String get contacts_groupNameRequired;
|
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.
|
/// No description provided for @contacts_groupAlreadyExists.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -902,6 +902,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Името на групата е задължително.';
|
String get contacts_groupNameRequired => 'Името на групата е задължително.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Това име на група е запазено';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Групата \"$name\" вече съществува.';
|
return 'Групата \"$name\" вече съществува.';
|
||||||
|
|||||||
@@ -902,6 +902,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Der Gruppennamen ist erforderlich.';
|
String get contacts_groupNameRequired => 'Der Gruppennamen ist erforderlich.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Dieser Gruppenname ist reserviert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Die Gruppe \"$name\" existiert bereits.';
|
return 'Die Gruppe \"$name\" existiert bereits.';
|
||||||
|
|||||||
@@ -889,6 +889,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Group name is required';
|
String get contacts_groupNameRequired => 'Group name is required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'This group name is reserved';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Group \"$name\" already exists';
|
return 'Group \"$name\" already exists';
|
||||||
|
|||||||
@@ -901,6 +901,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'El nombre del grupo es obligatorio';
|
String get contacts_groupNameRequired => 'El nombre del grupo es obligatorio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved =>
|
||||||
|
'Este nombre de grupo está reservado';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'El grupo \"$name\" ya existe';
|
return 'El grupo \"$name\" ya existe';
|
||||||
|
|||||||
@@ -905,6 +905,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Le nom du groupe est obligatoire.';
|
String get contacts_groupNameRequired => 'Le nom du groupe est obligatoire.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Ce nom de groupe est réservé';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Le groupe \"$name\" existe déjà.';
|
return 'Le groupe \"$name\" existe déjà.';
|
||||||
|
|||||||
@@ -901,6 +901,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Il nome del gruppo è obbligatorio.';
|
String get contacts_groupNameRequired => 'Il nome del gruppo è obbligatorio.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Questo nome del gruppo è riservato';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Il gruppo \"$name\" esiste già.';
|
return 'Il gruppo \"$name\" esiste già.';
|
||||||
|
|||||||
@@ -895,6 +895,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'De groepnaam is verplicht.';
|
String get contacts_groupNameRequired => 'De groepnaam is verplicht.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Deze groepsnaam is gereserveerd';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'De groep \"$name\" bestaat al.';
|
return 'De groep \"$name\" bestaat al.';
|
||||||
|
|||||||
@@ -904,6 +904,9 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Nazwa grupy jest wymagana';
|
String get contacts_groupNameRequired => 'Nazwa grupy jest wymagana';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Ta nazwa grupy jest zastrzeżona';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Grupa \"$name\" już istnieje';
|
return 'Grupa \"$name\" już istnieje';
|
||||||
|
|||||||
@@ -903,6 +903,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'O nome do grupo é obrigatório.';
|
String get contacts_groupNameRequired => 'O nome do grupo é obrigatório.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Este nome de grupo está reservado';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'O grupo \"$name\" já existe';
|
return 'O grupo \"$name\" já existe';
|
||||||
|
|||||||
@@ -902,6 +902,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Имя группы обязательно';
|
String get contacts_groupNameRequired => 'Имя группы обязательно';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Это имя группы зарезервировано';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Группа \"$name\" уже существует';
|
return 'Группа \"$name\" уже существует';
|
||||||
|
|||||||
@@ -894,6 +894,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Skupina musí mať názov.';
|
String get contacts_groupNameRequired => 'Skupina musí mať názov.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Tento názov skupiny je rezervovaný';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Skupina \"$name\" už existuje';
|
return 'Skupina \"$name\" už existuje';
|
||||||
|
|||||||
@@ -892,6 +892,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Ime skupine je obvezno.';
|
String get contacts_groupNameRequired => 'Ime skupine je obvezno.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'To ime skupine je rezervirano';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Skupina \"$name\" že obstaja';
|
return 'Skupina \"$name\" že obstaja';
|
||||||
|
|||||||
@@ -888,6 +888,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Gruppnamnet är obligatoriskt';
|
String get contacts_groupNameRequired => 'Gruppnamnet är obligatoriskt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Detta gruppnamn är reserverat';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Gruppen \"$name\" finns redan.';
|
return 'Gruppen \"$name\" finns redan.';
|
||||||
|
|||||||
@@ -898,6 +898,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Назва групи обов\'язкова.';
|
String get contacts_groupNameRequired => 'Назва групи обов\'язкова.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Ця назва групи зарезервована';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Група «$name» вже існує.';
|
return 'Група «$name» вже існує.';
|
||||||
|
|||||||
@@ -845,6 +845,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => '请输入群聊名称';
|
String get contacts_groupNameRequired => '请输入群聊名称';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => '该群组名称已被保留';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return '名为 \"$name\" 的群聊已存在';
|
return '名为 \"$name\" 的群聊已存在';
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Nieuwe Groep",
|
"contacts_newGroup": "Nieuwe Groep",
|
||||||
"contacts_groupName": "Groepnaam",
|
"contacts_groupName": "Groepnaam",
|
||||||
"contacts_groupNameRequired": "De groepnaam is verplicht.",
|
"contacts_groupNameRequired": "De groepnaam is verplicht.",
|
||||||
|
"contacts_groupNameReserved": "Deze groepsnaam is gereserveerd",
|
||||||
"contacts_groupAlreadyExists": "De groep \"{name}\" bestaat al.",
|
"contacts_groupAlreadyExists": "De groep \"{name}\" bestaat al.",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Nowa Grupa",
|
"contacts_newGroup": "Nowa Grupa",
|
||||||
"contacts_groupName": "Nazwa grupy",
|
"contacts_groupName": "Nazwa grupy",
|
||||||
"contacts_groupNameRequired": "Nazwa grupy jest wymagana",
|
"contacts_groupNameRequired": "Nazwa grupy jest wymagana",
|
||||||
|
"contacts_groupNameReserved": "Ta nazwa grupy jest zastrzeżona",
|
||||||
"contacts_groupAlreadyExists": "Grupa \"{name}\" już istnieje",
|
"contacts_groupAlreadyExists": "Grupa \"{name}\" już istnieje",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Novo Grupo",
|
"contacts_newGroup": "Novo Grupo",
|
||||||
"contacts_groupName": "Nome do grupo",
|
"contacts_groupName": "Nome do grupo",
|
||||||
"contacts_groupNameRequired": "O nome do grupo é obrigatório.",
|
"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": "O grupo \"{name}\" já existe",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -212,6 +212,7 @@
|
|||||||
"contacts_newGroup": "Новая группа",
|
"contacts_newGroup": "Новая группа",
|
||||||
"contacts_groupName": "Имя группы",
|
"contacts_groupName": "Имя группы",
|
||||||
"contacts_groupNameRequired": "Имя группы обязательно",
|
"contacts_groupNameRequired": "Имя группы обязательно",
|
||||||
|
"contacts_groupNameReserved": "Это имя группы зарезервировано",
|
||||||
"contacts_groupAlreadyExists": "Группа \"{name}\" уже существует",
|
"contacts_groupAlreadyExists": "Группа \"{name}\" уже существует",
|
||||||
"contacts_filterContacts": "Фильтр контактов...",
|
"contacts_filterContacts": "Фильтр контактов...",
|
||||||
"contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру",
|
"contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру",
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Nová skupina",
|
"contacts_newGroup": "Nová skupina",
|
||||||
"contacts_groupName": "Názov skupiny",
|
"contacts_groupName": "Názov skupiny",
|
||||||
"contacts_groupNameRequired": "Skupina musí mať názov.",
|
"contacts_groupNameRequired": "Skupina musí mať názov.",
|
||||||
|
"contacts_groupNameReserved": "Tento názov skupiny je rezervovaný",
|
||||||
"contacts_groupAlreadyExists": "Skupina \"{name}\" už existuje",
|
"contacts_groupAlreadyExists": "Skupina \"{name}\" už existuje",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Nova skupina",
|
"contacts_newGroup": "Nova skupina",
|
||||||
"contacts_groupName": "Ime skupine",
|
"contacts_groupName": "Ime skupine",
|
||||||
"contacts_groupNameRequired": "Ime skupine je obvezno.",
|
"contacts_groupNameRequired": "Ime skupine je obvezno.",
|
||||||
|
"contacts_groupNameReserved": "To ime skupine je rezervirano",
|
||||||
"contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja",
|
"contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Ny grupp",
|
"contacts_newGroup": "Ny grupp",
|
||||||
"contacts_groupName": "Gruppnamn",
|
"contacts_groupName": "Gruppnamn",
|
||||||
"contacts_groupNameRequired": "Gruppnamnet är obligatoriskt",
|
"contacts_groupNameRequired": "Gruppnamnet är obligatoriskt",
|
||||||
|
"contacts_groupNameReserved": "Detta gruppnamn är reserverat",
|
||||||
"contacts_groupAlreadyExists": "Gruppen \"{name}\" finns redan.",
|
"contacts_groupAlreadyExists": "Gruppen \"{name}\" finns redan.",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -286,6 +286,7 @@
|
|||||||
"contacts_newGroup": "Нова група",
|
"contacts_newGroup": "Нова група",
|
||||||
"contacts_groupName": "Назва групи",
|
"contacts_groupName": "Назва групи",
|
||||||
"contacts_groupNameRequired": "Назва групи обов'язкова.",
|
"contacts_groupNameRequired": "Назва групи обов'язкова.",
|
||||||
|
"contacts_groupNameReserved": "Ця назва групи зарезервована",
|
||||||
"contacts_groupAlreadyExists": "Група «{name}» вже існує.",
|
"contacts_groupAlreadyExists": "Група «{name}» вже існує.",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -300,6 +300,7 @@
|
|||||||
"contacts_newGroup": "新建群聊",
|
"contacts_newGroup": "新建群聊",
|
||||||
"contacts_groupName": "群聊名称",
|
"contacts_groupName": "群聊名称",
|
||||||
"contacts_groupNameRequired": "请输入群聊名称",
|
"contacts_groupNameRequired": "请输入群聊名称",
|
||||||
|
"contacts_groupNameReserved": "该群组名称已被保留",
|
||||||
"contacts_groupAlreadyExists": "名为 \"{name}\" 的群聊已存在",
|
"contacts_groupAlreadyExists": "名为 \"{name}\" 的群聊已存在",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import 'services/app_debug_log_service.dart';
|
|||||||
import 'services/background_service.dart';
|
import 'services/background_service.dart';
|
||||||
import 'services/map_tile_cache_service.dart';
|
import 'services/map_tile_cache_service.dart';
|
||||||
import 'services/chat_text_scale_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 'storage/prefs_manager.dart';
|
||||||
import 'utils/app_logger.dart';
|
import 'utils/app_logger.dart';
|
||||||
|
|
||||||
@@ -39,6 +41,8 @@ void main() async {
|
|||||||
final backgroundService = BackgroundService();
|
final backgroundService = BackgroundService();
|
||||||
final mapTileCacheService = MapTileCacheService();
|
final mapTileCacheService = MapTileCacheService();
|
||||||
final chatTextScaleService = ChatTextScaleService();
|
final chatTextScaleService = ChatTextScaleService();
|
||||||
|
final uiViewStateService = UiViewStateService();
|
||||||
|
final timeoutPredictionService = TimeoutPredictionService(storage);
|
||||||
|
|
||||||
// Load settings
|
// Load settings
|
||||||
await appSettingsService.loadSettings();
|
await appSettingsService.loadSettings();
|
||||||
@@ -56,6 +60,8 @@ void main() async {
|
|||||||
_registerThirdPartyLicenses();
|
_registerThirdPartyLicenses();
|
||||||
|
|
||||||
await chatTextScaleService.initialize();
|
await chatTextScaleService.initialize();
|
||||||
|
await uiViewStateService.initialize();
|
||||||
|
await timeoutPredictionService.initialize();
|
||||||
|
|
||||||
// Wire up connector with services
|
// Wire up connector with services
|
||||||
connector.initialize(
|
connector.initialize(
|
||||||
@@ -65,6 +71,7 @@ void main() async {
|
|||||||
bleDebugLogService: bleDebugLogService,
|
bleDebugLogService: bleDebugLogService,
|
||||||
appDebugLogService: appDebugLogService,
|
appDebugLogService: appDebugLogService,
|
||||||
backgroundService: backgroundService,
|
backgroundService: backgroundService,
|
||||||
|
timeoutPredictionService: timeoutPredictionService,
|
||||||
);
|
);
|
||||||
|
|
||||||
await connector.loadContactCache();
|
await connector.loadContactCache();
|
||||||
@@ -86,6 +93,8 @@ void main() async {
|
|||||||
appDebugLogService: appDebugLogService,
|
appDebugLogService: appDebugLogService,
|
||||||
mapTileCacheService: mapTileCacheService,
|
mapTileCacheService: mapTileCacheService,
|
||||||
chatTextScaleService: chatTextScaleService,
|
chatTextScaleService: chatTextScaleService,
|
||||||
|
uiViewStateService: uiViewStateService,
|
||||||
|
timeoutPredictionService: timeoutPredictionService,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -121,6 +130,8 @@ class MeshCoreApp extends StatelessWidget {
|
|||||||
final AppDebugLogService appDebugLogService;
|
final AppDebugLogService appDebugLogService;
|
||||||
final MapTileCacheService mapTileCacheService;
|
final MapTileCacheService mapTileCacheService;
|
||||||
final ChatTextScaleService chatTextScaleService;
|
final ChatTextScaleService chatTextScaleService;
|
||||||
|
final UiViewStateService uiViewStateService;
|
||||||
|
final TimeoutPredictionService timeoutPredictionService;
|
||||||
|
|
||||||
const MeshCoreApp({
|
const MeshCoreApp({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -133,6 +144,8 @@ class MeshCoreApp extends StatelessWidget {
|
|||||||
required this.appDebugLogService,
|
required this.appDebugLogService,
|
||||||
required this.mapTileCacheService,
|
required this.mapTileCacheService,
|
||||||
required this.chatTextScaleService,
|
required this.chatTextScaleService,
|
||||||
|
required this.uiViewStateService,
|
||||||
|
required this.timeoutPredictionService,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -146,8 +159,10 @@ class MeshCoreApp extends StatelessWidget {
|
|||||||
ChangeNotifierProvider.value(value: bleDebugLogService),
|
ChangeNotifierProvider.value(value: bleDebugLogService),
|
||||||
ChangeNotifierProvider.value(value: appDebugLogService),
|
ChangeNotifierProvider.value(value: appDebugLogService),
|
||||||
ChangeNotifierProvider.value(value: chatTextScaleService),
|
ChangeNotifierProvider.value(value: chatTextScaleService),
|
||||||
|
ChangeNotifierProvider.value(value: uiViewStateService),
|
||||||
Provider.value(value: storage),
|
Provider.value(value: storage),
|
||||||
Provider.value(value: mapTileCacheService),
|
Provider.value(value: mapTileCacheService),
|
||||||
|
ChangeNotifierProvider.value(value: timeoutPredictionService),
|
||||||
],
|
],
|
||||||
child: Consumer<AppSettingsService>(
|
child: Consumer<AppSettingsService>(
|
||||||
builder: (context, settingsService, child) {
|
builder: (context, settingsService, child) {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
class DeliveryObservation {
|
||||||
|
final String contactKey;
|
||||||
|
final int pathLength;
|
||||||
|
final int messageBytes;
|
||||||
|
final int secondsSinceLastRx;
|
||||||
|
final bool isFlood;
|
||||||
|
final int deliveryMs;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
DeliveryObservation({
|
||||||
|
required this.contactKey,
|
||||||
|
required this.pathLength,
|
||||||
|
required this.messageBytes,
|
||||||
|
required this.secondsSinceLastRx,
|
||||||
|
required this.isFlood,
|
||||||
|
required this.deliveryMs,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'contact_key': contactKey,
|
||||||
|
'path_length': pathLength,
|
||||||
|
'message_bytes': messageBytes,
|
||||||
|
'seconds_since_last_rx': secondsSinceLastRx,
|
||||||
|
'is_flood': isFlood,
|
||||||
|
'delivery_ms': deliveryMs,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory DeliveryObservation.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DeliveryObservation(
|
||||||
|
contactKey: json['contact_key'] as String,
|
||||||
|
pathLength: json['path_length'] as int,
|
||||||
|
messageBytes: json['message_bytes'] as int,
|
||||||
|
secondsSinceLastRx: json['seconds_since_last_rx'] as int? ?? 0,
|
||||||
|
isFlood: json['is_flood'] as bool,
|
||||||
|
deliveryMs: json['delivery_ms'] as int,
|
||||||
|
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import 'package:uuid/uuid.dart';
|
|||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../services/app_settings_service.dart';
|
import '../services/app_settings_service.dart';
|
||||||
|
import '../services/ui_view_state_service.dart';
|
||||||
import '../models/channel.dart';
|
import '../models/channel.dart';
|
||||||
import '../models/community.dart';
|
import '../models/community.dart';
|
||||||
import '../storage/community_store.dart';
|
import '../storage/community_store.dart';
|
||||||
@@ -28,8 +29,6 @@ import 'contacts_screen.dart';
|
|||||||
import 'map_screen.dart';
|
import 'map_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
enum ChannelSortOption { manual, name, latestMessages, unread }
|
|
||||||
|
|
||||||
class ChannelsScreen extends StatefulWidget {
|
class ChannelsScreen extends StatefulWidget {
|
||||||
final bool hideBackButton;
|
final bool hideBackButton;
|
||||||
|
|
||||||
@@ -43,9 +42,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
with DisconnectNavigationMixin {
|
with DisconnectNavigationMixin {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final CommunityStore _communityStore = CommunityStore();
|
final CommunityStore _communityStore = CommunityStore();
|
||||||
String _searchQuery = '';
|
|
||||||
Timer? _searchDebounce;
|
Timer? _searchDebounce;
|
||||||
ChannelSortOption _sortOption = ChannelSortOption.manual;
|
|
||||||
List<Community> _communities = [];
|
List<Community> _communities = [];
|
||||||
|
|
||||||
// Cache of PSK hex -> Community for quick lookup
|
// Cache of PSK hex -> Community for quick lookup
|
||||||
@@ -56,6 +53,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_searchController.text = context
|
||||||
|
.read<UiViewStateService>()
|
||||||
|
.channelsSearchText;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<MeshCoreConnector>().getChannels();
|
context.read<MeshCoreConnector>().getChannels();
|
||||||
_loadCommunities();
|
_loadCommunities();
|
||||||
@@ -110,6 +110,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final connector = context.watch<MeshCoreConnector>();
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
|
final viewState = context.watch<UiViewStateService>();
|
||||||
|
|
||||||
final channelMessageStore = ChannelMessageStore();
|
final channelMessageStore = ChannelMessageStore();
|
||||||
channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
@@ -205,6 +206,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
final filteredChannels = _filterAndSortChannels(
|
final filteredChannels = _filterAndSortChannels(
|
||||||
channels,
|
channels,
|
||||||
connector,
|
connector,
|
||||||
|
viewState,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -219,17 +221,19 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
suffixIcon: Row(
|
suffixIcon: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (_searchQuery.isNotEmpty)
|
if (viewState.channelsSearchText.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
_searchDebounce?.cancel();
|
||||||
|
_searchDebounce = null;
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
setState(() {
|
context
|
||||||
_searchQuery = '';
|
.read<UiViewStateService>()
|
||||||
});
|
.setChannelsSearchText('');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_buildFilterButton(),
|
_buildFilterButton(viewState),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
@@ -246,9 +250,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
const Duration(milliseconds: 300),
|
const Duration(milliseconds: 300),
|
||||||
() {
|
() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
context
|
||||||
_searchQuery = value.toLowerCase();
|
.read<UiViewStateService>()
|
||||||
});
|
.setChannelsSearchText(value);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -283,8 +287,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: (_sortOption == ChannelSortOption.manual &&
|
: (viewState.channelsSortOption ==
|
||||||
_searchQuery.isEmpty)
|
ChannelSortOption.manual &&
|
||||||
|
viewState.channelsSearchText.isEmpty)
|
||||||
? ReorderableListView.builder(
|
? ReorderableListView.builder(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
left: 16,
|
left: 16,
|
||||||
@@ -584,59 +589,40 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
await showDisconnectDialog(context, connector);
|
await showDisconnectDialog(context, connector);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilterButton() {
|
Widget _buildFilterButton(UiViewStateService viewState) {
|
||||||
const actionSortManual = 0;
|
return SortFilterMenu<ChannelSortOption>(
|
||||||
const actionSortName = 1;
|
|
||||||
const actionSortLatest = 2;
|
|
||||||
const actionSortUnread = 3;
|
|
||||||
|
|
||||||
return SortFilterMenu(
|
|
||||||
tooltip: context.l10n.listFilter_tooltip,
|
tooltip: context.l10n.listFilter_tooltip,
|
||||||
sections: [
|
sections: [
|
||||||
SortFilterMenuSection(
|
SortFilterMenuSection<ChannelSortOption>(
|
||||||
title: context.l10n.channels_sortBy,
|
title: context.l10n.channels_sortBy,
|
||||||
options: [
|
options: [
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption<ChannelSortOption>(
|
||||||
value: actionSortManual,
|
value: ChannelSortOption.manual,
|
||||||
label: context.l10n.channels_sortManual,
|
label: context.l10n.channels_sortManual,
|
||||||
checked: _sortOption == ChannelSortOption.manual,
|
checked: viewState.channelsSortOption == ChannelSortOption.manual,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption<ChannelSortOption>(
|
||||||
value: actionSortName,
|
value: ChannelSortOption.name,
|
||||||
label: context.l10n.channels_sortAZ,
|
label: context.l10n.channels_sortAZ,
|
||||||
checked: _sortOption == ChannelSortOption.name,
|
checked: viewState.channelsSortOption == ChannelSortOption.name,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption<ChannelSortOption>(
|
||||||
value: actionSortLatest,
|
value: ChannelSortOption.latestMessages,
|
||||||
label: context.l10n.channels_sortLatestMessages,
|
label: context.l10n.channels_sortLatestMessages,
|
||||||
checked: _sortOption == ChannelSortOption.latestMessages,
|
checked:
|
||||||
|
viewState.channelsSortOption ==
|
||||||
|
ChannelSortOption.latestMessages,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption<ChannelSortOption>(
|
||||||
value: actionSortUnread,
|
value: ChannelSortOption.unread,
|
||||||
label: context.l10n.channels_sortUnread,
|
label: context.l10n.channels_sortUnread,
|
||||||
checked: _sortOption == ChannelSortOption.unread,
|
checked: viewState.channelsSortOption == ChannelSortOption.unread,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onSelected: (action) {
|
onSelected: (sortOption) {
|
||||||
setState(() {
|
viewState.setChannelsSortOption(sortOption);
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -644,11 +630,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
List<Channel> _filterAndSortChannels(
|
List<Channel> _filterAndSortChannels(
|
||||||
List<Channel> channels,
|
List<Channel> channels,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
|
UiViewStateService viewState,
|
||||||
) {
|
) {
|
||||||
var filtered = channels.where((channel) {
|
var filtered = channels.where((channel) {
|
||||||
if (_searchQuery.isEmpty) return true;
|
if (viewState.channelsSearchText.isEmpty) return true;
|
||||||
final label = _normalizeChannelName(channel);
|
final label = _normalizeChannelName(channel);
|
||||||
return label.toLowerCase().contains(_searchQuery);
|
return label.toLowerCase().contains(
|
||||||
|
viewState.channelsSearchText.toLowerCase(),
|
||||||
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
int compareByName(Channel a, Channel b) {
|
int compareByName(Channel a, Channel b) {
|
||||||
@@ -657,7 +646,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
|
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (_sortOption) {
|
switch (viewState.channelsSortOption) {
|
||||||
case ChannelSortOption.manual:
|
case ChannelSortOption.manual:
|
||||||
break;
|
break;
|
||||||
case ChannelSortOption.latestMessages:
|
case ChannelSortOption.latestMessages:
|
||||||
|
|||||||
+393
-218
@@ -13,8 +13,9 @@ import '../l10n/l10n.dart';
|
|||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/contact_group.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 '../utils/contact_search.dart';
|
||||||
|
import '../storage/contact_group_store.dart';
|
||||||
import '../utils/dialog_utils.dart';
|
import '../utils/dialog_utils.dart';
|
||||||
import '../utils/disconnect_navigation_mixin.dart';
|
import '../utils/disconnect_navigation_mixin.dart';
|
||||||
import '../utils/emoji_utils.dart';
|
import '../utils/emoji_utils.dart';
|
||||||
@@ -48,12 +49,10 @@ class ContactsScreen extends StatefulWidget {
|
|||||||
class _ContactsScreenState extends State<ContactsScreen>
|
class _ContactsScreenState extends State<ContactsScreen>
|
||||||
with DisconnectNavigationMixin {
|
with DisconnectNavigationMixin {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
String _searchQuery = '';
|
|
||||||
ContactSortOption _sortOption = ContactSortOption.lastSeen;
|
|
||||||
bool _showUnreadOnly = false;
|
|
||||||
ContactTypeFilter _typeFilter = ContactTypeFilter.all;
|
|
||||||
final ContactGroupStore _groupStore = ContactGroupStore();
|
final ContactGroupStore _groupStore = ContactGroupStore();
|
||||||
|
MeshCoreConnector? _scopeSyncConnector;
|
||||||
List<ContactGroup> _groups = [];
|
List<ContactGroup> _groups = [];
|
||||||
|
String _loadedGroupScopeKeyHex = '';
|
||||||
Timer? _searchDebounce;
|
Timer? _searchDebounce;
|
||||||
|
|
||||||
final Set<ContactOperationType> _pendingOperations = {};
|
final Set<ContactOperationType> _pendingOperations = {};
|
||||||
@@ -63,6 +62,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_searchController.text = context
|
||||||
|
.read<UiViewStateService>()
|
||||||
|
.contactsSearchText;
|
||||||
_loadGroups();
|
_loadGroups();
|
||||||
_setupFrameListener();
|
_setupFrameListener();
|
||||||
_clearAdvertNotifications();
|
_clearAdvertNotifications();
|
||||||
@@ -74,26 +76,84 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
NotificationService().clearAdvertNotifications(contactIds);
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchDebounce?.cancel();
|
_searchDebounce?.cancel();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
_frameSubscription?.cancel();
|
_frameSubscription?.cancel();
|
||||||
|
_scopeSyncConnector?.removeListener(_handleConnectorScopeChange);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleConnectorScopeChange() {
|
||||||
|
final connector = _scopeSyncConnector;
|
||||||
|
if (connector == null) return;
|
||||||
|
_syncGroupScopeIfNeeded(connector);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadGroups() async {
|
Future<void> _loadGroups() async {
|
||||||
|
final selfPublicKeyHex = context.read<MeshCoreConnector>().selfPublicKeyHex;
|
||||||
|
if (selfPublicKeyHex.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_groupStore.setPublicKeyHex = selfPublicKeyHex;
|
||||||
final groups = await _groupStore.loadGroups();
|
final groups = await _groupStore.loadGroups();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_loadedGroupScopeKeyHex = selfPublicKeyHex;
|
||||||
_groups = groups;
|
_groups = groups;
|
||||||
|
_ensureValidSelectedGroup();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveGroups() async {
|
Future<void> _saveGroups() async {
|
||||||
|
final selfPublicKeyHex = context.read<MeshCoreConnector>().selfPublicKeyHex;
|
||||||
|
if (selfPublicKeyHex.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_groupStore.setPublicKeyHex = selfPublicKeyHex;
|
||||||
await _groupStore.saveGroups(_groups);
|
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() {
|
void _setupFrameListener() {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
// Listen for incoming text messages from the repeater
|
// Listen for incoming text messages from the repeater
|
||||||
@@ -383,31 +443,166 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
await showDisconnectDialog(context, connector);
|
await showDisconnectDialog(context, connector);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) {
|
ContactGroup? _selectedGroupForName(String selectedGroupName) {
|
||||||
return ContactsFilterMenu(
|
if (selectedGroupName == contactsAllGroupsValue) return null;
|
||||||
sortOption: _sortOption,
|
for (final group in _groups) {
|
||||||
typeFilter: _typeFilter,
|
if (group.name == selectedGroupName) return group;
|
||||||
showUnreadOnly: _showUnreadOnly,
|
}
|
||||||
onSortChanged: (value) {
|
return null;
|
||||||
setState(() {
|
}
|
||||||
_sortOption = value;
|
|
||||||
|
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: viewState.contactsSortOption,
|
||||||
|
typeFilter: viewState.contactsTypeFilter,
|
||||||
|
showUnreadOnly: viewState.contactsShowUnreadOnly,
|
||||||
|
onSortChanged: (value) {
|
||||||
|
viewState.setContactsSortOption(value);
|
||||||
},
|
},
|
||||||
onTypeFilterChanged: (value) {
|
onTypeFilterChanged: (value) {
|
||||||
setState(() {
|
viewState.setContactsTypeFilter(value);
|
||||||
_typeFilter = value;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onUnreadOnlyChanged: (value) {
|
onUnreadOnlyChanged: (value) {
|
||||||
setState(() {
|
viewState.setContactsShowUnreadOnly(value);
|
||||||
_showUnreadOnly = 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) {
|
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
final viewState = context.watch<UiViewStateService>();
|
||||||
final contacts = connector.contacts;
|
final contacts = connector.contacts;
|
||||||
final shouldShowStartupSpinner =
|
final shouldShowStartupSpinner =
|
||||||
contacts.isEmpty &&
|
contacts.isEmpty &&
|
||||||
@@ -429,92 +624,171 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
|
final filteredAndSorted = _filterAndSortContacts(
|
||||||
final filteredGroups = _showUnreadOnly
|
contacts,
|
||||||
? const <ContactGroup>[]
|
connector,
|
||||||
: _filterAndSortGroups(_groups, contacts);
|
viewState,
|
||||||
|
);
|
||||||
|
|
||||||
String hintText = "";
|
String hintText = "";
|
||||||
|
|
||||||
switch (_typeFilter) {
|
switch (viewState.contactsTypeFilter) {
|
||||||
case ContactTypeFilter.all:
|
case ContactTypeFilter.all:
|
||||||
hintText = context.l10n.contacts_searchContacts(
|
hintText = context.l10n.contacts_searchContacts(
|
||||||
filteredAndSorted.length,
|
filteredAndSorted.length,
|
||||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
viewState.contactsShowUnreadOnly
|
||||||
|
? " ${context.l10n.contacts_unread}"
|
||||||
|
: "",
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case ContactTypeFilter.users:
|
case ContactTypeFilter.users:
|
||||||
hintText = context.l10n.contacts_searchUsers(
|
hintText = context.l10n.contacts_searchUsers(
|
||||||
filteredAndSorted.length,
|
filteredAndSorted.length,
|
||||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
viewState.contactsShowUnreadOnly
|
||||||
|
? " ${context.l10n.contacts_unread}"
|
||||||
|
: "",
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case ContactTypeFilter.repeaters:
|
case ContactTypeFilter.repeaters:
|
||||||
hintText = context.l10n.contacts_searchRepeaters(
|
hintText = context.l10n.contacts_searchRepeaters(
|
||||||
filteredAndSorted.length,
|
filteredAndSorted.length,
|
||||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
viewState.contactsShowUnreadOnly
|
||||||
|
? " ${context.l10n.contacts_unread}"
|
||||||
|
: "",
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case ContactTypeFilter.rooms:
|
case ContactTypeFilter.rooms:
|
||||||
hintText = context.l10n.contacts_searchRoomServers(
|
hintText = context.l10n.contacts_searchRoomServers(
|
||||||
filteredAndSorted.length,
|
filteredAndSorted.length,
|
||||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
viewState.contactsShowUnreadOnly
|
||||||
|
? " ${context.l10n.contacts_unread}"
|
||||||
|
: "",
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case ContactTypeFilter.favorites:
|
case ContactTypeFilter.favorites:
|
||||||
hintText = context.l10n.contacts_searchFavorites(
|
hintText = context.l10n.contacts_searchFavorites(
|
||||||
filteredAndSorted.length,
|
filteredAndSorted.length,
|
||||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
viewState.contactsShowUnreadOnly
|
||||||
|
? " ${context.l10n.contacts_unread}"
|
||||||
|
: "",
|
||||||
);
|
);
|
||||||
break;
|
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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: TextField(
|
child: Row(
|
||||||
controller: _searchController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: hintText,
|
|
||||||
prefixIcon: const Icon(Icons.search),
|
|
||||||
suffixIcon: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
if (_searchQuery.isNotEmpty)
|
Expanded(
|
||||||
IconButton(
|
child: _buildGroupButton(
|
||||||
icon: const Icon(Icons.clear),
|
context,
|
||||||
onPressed: () {
|
connector,
|
||||||
_searchController.clear();
|
viewState,
|
||||||
setState(() {
|
contacts,
|
||||||
_searchQuery = '';
|
sortedGroups,
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
_buildFilterButton(context, connector),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
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,
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
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(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 12,
|
||||||
vertical: 12,
|
vertical: 10,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
_searchDebounce?.cancel();
|
_searchDebounce?.cancel();
|
||||||
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
_searchDebounce = Timer(
|
||||||
|
const Duration(milliseconds: 300),
|
||||||
|
() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
context
|
||||||
_searchQuery = value.toLowerCase();
|
.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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
|
child: filteredAndSorted.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -522,7 +796,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
_showUnreadOnly
|
viewState.contactsShowUnreadOnly
|
||||||
? context.l10n.contacts_noUnreadContacts
|
? context.l10n.contacts_noUnreadContacts
|
||||||
: context.l10n.contacts_noContactsFound,
|
: context.l10n.contacts_noContactsFound,
|
||||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||||
@@ -533,14 +807,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () => connector.getContacts(),
|
onRefresh: () => connector.getContacts(),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: filteredGroups.length + filteredAndSorted.length,
|
itemCount: filteredAndSorted.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index < filteredGroups.length) {
|
final contact = filteredAndSorted[index];
|
||||||
final group = filteredGroups[index];
|
|
||||||
return _buildGroupTile(context, group, contacts);
|
|
||||||
}
|
|
||||||
final contact =
|
|
||||||
filteredAndSorted[index - filteredGroups.length];
|
|
||||||
final unreadCount = connector.getUnreadCountForContact(
|
final unreadCount = connector.getUnreadCountForContact(
|
||||||
contact,
|
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> _filterAndSortContacts(
|
||||||
List<Contact> contacts,
|
List<Contact> contacts,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
|
UiViewStateService viewState,
|
||||||
) {
|
) {
|
||||||
var filtered = contacts.where((contact) {
|
var filtered = contacts.where((contact) {
|
||||||
if (_searchQuery.isEmpty) return true;
|
if (viewState.contactsSearchText.isEmpty) return true;
|
||||||
return matchesContactQuery(contact, _searchQuery);
|
return matchesContactQuery(contact, viewState.contactsSearchText);
|
||||||
}).toList();
|
}).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
|
// Filter out own node from the list
|
||||||
if (connector.selfPublicKey != null) {
|
if (connector.selfPublicKey != null) {
|
||||||
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
|
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
|
||||||
@@ -618,17 +858,22 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_typeFilter != ContactTypeFilter.all) {
|
if (viewState.contactsTypeFilter != ContactTypeFilter.all) {
|
||||||
filtered = filtered.where(_matchesTypeFilter).toList();
|
filtered = filtered
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
_matchesTypeFilter(contact, viewState.contactsTypeFilter),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_showUnreadOnly) {
|
if (viewState.contactsShowUnreadOnly) {
|
||||||
filtered = filtered.where((contact) {
|
filtered = filtered.where((contact) {
|
||||||
return connector.getUnreadCountForContact(contact) > 0;
|
return connector.getUnreadCountForContact(contact) > 0;
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (_sortOption) {
|
switch (viewState.contactsSortOption) {
|
||||||
case ContactSortOption.lastSeen:
|
case ContactSortOption.lastSeen:
|
||||||
filtered.sort(
|
filtered.sort(
|
||||||
(a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)),
|
(a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)),
|
||||||
@@ -657,8 +902,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _matchesTypeFilter(Contact contact) {
|
bool _matchesTypeFilter(Contact contact, ContactTypeFilter typeFilter) {
|
||||||
switch (_typeFilter) {
|
switch (typeFilter) {
|
||||||
case ContactTypeFilter.all:
|
case ContactTypeFilter.all:
|
||||||
return true;
|
return true;
|
||||||
case ContactTypeFilter.favorites:
|
case ContactTypeFilter.favorites:
|
||||||
@@ -679,57 +924,6 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
: contact.lastSeen;
|
: 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) {
|
void _openChat(BuildContext context, Contact contact) {
|
||||||
// Check if this is a repeater
|
// Check if this is a repeater
|
||||||
if (contact.type == advTypeRepeater) {
|
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) {
|
void _confirmDeleteGroup(BuildContext context, ContactGroup group) {
|
||||||
|
if (!_hasGroupStoreScope(context.read<MeshCoreConnector>())) {
|
||||||
|
_showGroupsUnavailableMessage(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
@@ -874,6 +1021,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
setState(() {
|
setState(() {
|
||||||
_groups.removeWhere((g) => g.name == group.name);
|
_groups.removeWhere((g) => g.name == group.name);
|
||||||
|
_ensureValidSelectedGroup();
|
||||||
});
|
});
|
||||||
await _saveGroups();
|
await _saveGroups();
|
||||||
},
|
},
|
||||||
@@ -892,6 +1040,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
List<Contact> contacts, {
|
List<Contact> contacts, {
|
||||||
ContactGroup? group,
|
ContactGroup? group,
|
||||||
}) {
|
}) {
|
||||||
|
if (!_hasGroupStoreScope(context.read<MeshCoreConnector>())) {
|
||||||
|
_showGroupsUnavailableMessage(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final isEditing = group != null;
|
final isEditing = group != null;
|
||||||
final nameController = TextEditingController(text: group?.name ?? '');
|
final nameController = TextEditingController(text: group?.name ?? '');
|
||||||
final selectedKeys = <String>{...group?.memberKeys ?? []};
|
final selectedKeys = <String>{...group?.memberKeys ?? []};
|
||||||
@@ -918,6 +1070,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
),
|
),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: double.maxFinite,
|
width: double.maxFinite,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -943,8 +1099,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SizedBox(
|
Expanded(
|
||||||
height: 240,
|
|
||||||
child: filteredContacts.isEmpty
|
child: filteredContacts.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -967,7 +1122,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
if (value == true) {
|
if (value == true) {
|
||||||
selectedKeys.add(contact.publicKeyHex);
|
selectedKeys.add(contact.publicKeyHex);
|
||||||
} else {
|
} else {
|
||||||
selectedKeys.remove(contact.publicKeyHex);
|
selectedKeys.remove(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -978,6 +1135,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(dialogContext),
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
@@ -994,6 +1152,15 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (name.toLowerCase() ==
|
||||||
|
contactsAllGroupsValue.toLowerCase()) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.contacts_groupNameReserved),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final exists = _groups.any((g) {
|
final exists = _groups.any((g) {
|
||||||
if (isEditing && g.name == group.name) return false;
|
if (isEditing && g.name == group.name) return false;
|
||||||
return g.name.toLowerCase() == name.toLowerCase();
|
return g.name.toLowerCase() == name.toLowerCase();
|
||||||
@@ -1009,15 +1176,21 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
|
final viewState = context.read<UiViewStateService>();
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
final index = _groups.indexWhere(
|
final index = _groups.indexWhere(
|
||||||
(g) => g.name == group.name,
|
(g) => g.name == group.name,
|
||||||
);
|
);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
|
final wasSelected =
|
||||||
|
viewState.contactsSelectedGroupName == group.name;
|
||||||
_groups[index] = ContactGroup(
|
_groups[index] = ContactGroup(
|
||||||
name: name,
|
name: name,
|
||||||
memberKeys: selectedKeys.toList(),
|
memberKeys: selectedKeys.toList(),
|
||||||
);
|
);
|
||||||
|
if (wasSelected) {
|
||||||
|
viewState.setContactsSelectedGroupName(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_groups.add(
|
_groups.add(
|
||||||
@@ -1026,7 +1199,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
memberKeys: selectedKeys.toList(),
|
memberKeys: selectedKeys.toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
viewState.setContactsSelectedGroupName(name);
|
||||||
}
|
}
|
||||||
|
_ensureValidSelectedGroup();
|
||||||
});
|
});
|
||||||
await _saveGroups();
|
await _saveGroups();
|
||||||
if (dialogContext.mounted) {
|
if (dialogContext.mounted) {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class ChatTextScaleService extends ChangeNotifier {
|
|||||||
|
|
||||||
void _commitScale() {
|
void _commitScale() {
|
||||||
_saveTimer?.cancel();
|
_saveTimer?.cancel();
|
||||||
PrefsManager.instance.setDouble(_prefKey, _scale);
|
unawaited(PrefsManager.instance.setDouble(_prefKey, _scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble();
|
double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble();
|
||||||
|
|||||||
@@ -58,12 +58,13 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
Function(Message)? _updateMessageCallback;
|
Function(Message)? _updateMessageCallback;
|
||||||
Function(Contact)? _clearContactPathCallback;
|
Function(Contact)? _clearContactPathCallback;
|
||||||
Function(Contact, Uint8List, int)? _setContactPathCallback;
|
Function(Contact, Uint8List, int)? _setContactPathCallback;
|
||||||
Function(int, int)? _calculateTimeoutCallback;
|
Function(int, int, {String? contactKey})? _calculateTimeoutCallback;
|
||||||
Uint8List? Function()? _getSelfPublicKeyCallback;
|
Uint8List? Function()? _getSelfPublicKeyCallback;
|
||||||
String Function(Contact, String)? _prepareContactOutboundTextCallback;
|
String Function(Contact, String)? _prepareContactOutboundTextCallback;
|
||||||
AppSettingsService? _appSettingsService;
|
AppSettingsService? _appSettingsService;
|
||||||
AppDebugLogService? _debugLogService;
|
AppDebugLogService? _debugLogService;
|
||||||
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
|
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
|
||||||
|
Function(String, int, int, int)? _onDeliveryObservedCallback;
|
||||||
|
|
||||||
MessageRetryService();
|
MessageRetryService();
|
||||||
|
|
||||||
@@ -73,12 +74,20 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
required Function(Message) updateMessageCallback,
|
required Function(Message) updateMessageCallback,
|
||||||
Function(Contact)? clearContactPathCallback,
|
Function(Contact)? clearContactPathCallback,
|
||||||
Function(Contact, Uint8List, int)? setContactPathCallback,
|
Function(Contact, Uint8List, int)? setContactPathCallback,
|
||||||
Function(int pathLength, int messageBytes)? calculateTimeoutCallback,
|
Function(int pathLength, int messageBytes, {String? contactKey})?
|
||||||
|
calculateTimeoutCallback,
|
||||||
Uint8List? Function()? getSelfPublicKeyCallback,
|
Uint8List? Function()? getSelfPublicKeyCallback,
|
||||||
String Function(Contact, String)? prepareContactOutboundTextCallback,
|
String Function(Contact, String)? prepareContactOutboundTextCallback,
|
||||||
AppSettingsService? appSettingsService,
|
AppSettingsService? appSettingsService,
|
||||||
AppDebugLogService? debugLogService,
|
AppDebugLogService? debugLogService,
|
||||||
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
|
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
|
||||||
|
Function(
|
||||||
|
String contactKey,
|
||||||
|
int pathLength,
|
||||||
|
int messageBytes,
|
||||||
|
int tripTimeMs,
|
||||||
|
)?
|
||||||
|
onDeliveryObservedCallback,
|
||||||
}) {
|
}) {
|
||||||
_sendMessageCallback = sendMessageCallback;
|
_sendMessageCallback = sendMessageCallback;
|
||||||
_addMessageCallback = addMessageCallback;
|
_addMessageCallback = addMessageCallback;
|
||||||
@@ -91,6 +100,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
_appSettingsService = appSettingsService;
|
_appSettingsService = appSettingsService;
|
||||||
_debugLogService = debugLogService;
|
_debugLogService = debugLogService;
|
||||||
_recordPathResultCallback = recordPathResultCallback;
|
_recordPathResultCallback = recordPathResultCallback;
|
||||||
|
_onDeliveryObservedCallback = onDeliveryObservedCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute expected ACK hash using same algorithm as firmware:
|
/// Compute expected ACK hash using same algorithm as firmware:
|
||||||
@@ -423,9 +433,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
|
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
|
||||||
int actualTimeout = timeoutMs;
|
|
||||||
if (timeoutMs <= 0 && _calculateTimeoutCallback != null) {
|
|
||||||
int pathLengthValue;
|
int pathLengthValue;
|
||||||
if (selection != null) {
|
if (selection != null) {
|
||||||
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||||
@@ -435,14 +443,24 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
pathLengthValue = contact.pathLength;
|
pathLengthValue = contact.pathLength;
|
||||||
}
|
}
|
||||||
actualTimeout = _calculateTimeoutCallback!(
|
|
||||||
|
int actualTimeout = timeoutMs;
|
||||||
|
if (_calculateTimeoutCallback != null) {
|
||||||
|
final calculated = _calculateTimeoutCallback!(
|
||||||
pathLengthValue,
|
pathLengthValue,
|
||||||
message.text.length,
|
message.text.length,
|
||||||
|
contactKey: contact.publicKeyHex,
|
||||||
);
|
);
|
||||||
|
// calculateTimeout tries ML first, falls back to physics.
|
||||||
|
// Use calculated value if device didn't provide one, or if ML
|
||||||
|
// produced a tighter prediction than the device's estimate.
|
||||||
|
if (timeoutMs <= 0 || calculated < timeoutMs) {
|
||||||
|
actualTimeout = calculated;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
|
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final updatedMessage = message.copyWith(
|
final updatedMessage = message.copyWith(
|
||||||
status: MessageStatus.sent,
|
status: MessageStatus.sent,
|
||||||
@@ -738,6 +756,16 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
true,
|
true,
|
||||||
tripTimeMs,
|
tripTimeMs,
|
||||||
);
|
);
|
||||||
|
if (_onDeliveryObservedCallback != null &&
|
||||||
|
tripTimeMs > 0 &&
|
||||||
|
message.pathLength != null) {
|
||||||
|
_onDeliveryObservedCallback!(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
message.pathLength!,
|
||||||
|
message.text.length,
|
||||||
|
tripTimeMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
|
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import '../models/delivery_observation.dart';
|
||||||
import '../models/path_history.dart';
|
import '../models/path_history.dart';
|
||||||
import '../storage/prefs_manager.dart';
|
import '../storage/prefs_manager.dart';
|
||||||
|
|
||||||
@@ -6,6 +7,7 @@ class StorageService {
|
|||||||
static const String _pathHistoryPrefix = 'path_history_';
|
static const String _pathHistoryPrefix = 'path_history_';
|
||||||
static const String _pendingMessagesKey = 'pending_messages';
|
static const String _pendingMessagesKey = 'pending_messages';
|
||||||
static const String _repeaterPasswordsKey = 'repeater_passwords';
|
static const String _repeaterPasswordsKey = 'repeater_passwords';
|
||||||
|
static const String _deliveryObservationsKey = 'delivery_observations';
|
||||||
|
|
||||||
Future<void> savePathHistory(
|
Future<void> savePathHistory(
|
||||||
String contactPubKeyHex,
|
String contactPubKeyHex,
|
||||||
@@ -122,4 +124,33 @@ class StorageService {
|
|||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
await prefs.remove(_repeaterPasswordsKey);
|
await prefs.remove(_repeaterPasswordsKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveDeliveryObservations(
|
||||||
|
List<DeliveryObservation> observations,
|
||||||
|
) async {
|
||||||
|
final prefs = PrefsManager.instance;
|
||||||
|
final jsonStr = jsonEncode(observations.map((o) => o.toJson()).toList());
|
||||||
|
await prefs.setString(_deliveryObservationsKey, jsonStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<DeliveryObservation>> loadDeliveryObservations() async {
|
||||||
|
final prefs = PrefsManager.instance;
|
||||||
|
final jsonStr = prefs.getString(_deliveryObservationsKey);
|
||||||
|
|
||||||
|
if (jsonStr == null) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(jsonStr) as List;
|
||||||
|
return list
|
||||||
|
.map((e) => DeliveryObservation.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearDeliveryObservations() async {
|
||||||
|
final prefs = PrefsManager.instance;
|
||||||
|
await prefs.remove(_deliveryObservationsKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:ml_algo/ml_algo.dart';
|
||||||
|
import 'package:ml_dataframe/ml_dataframe.dart';
|
||||||
|
import '../models/delivery_observation.dart';
|
||||||
|
import 'storage_service.dart';
|
||||||
|
|
||||||
|
class _ContactStats {
|
||||||
|
int count = 0;
|
||||||
|
double _sum = 0;
|
||||||
|
|
||||||
|
void add(double ms) {
|
||||||
|
count++;
|
||||||
|
_sum += ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
double get mean => _sum / count;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimeoutPredictionService extends ChangeNotifier {
|
||||||
|
final StorageService? _storage;
|
||||||
|
|
||||||
|
static const int minObservations = 10;
|
||||||
|
static const int maxObservations = 100;
|
||||||
|
static const int _retrainInterval = 5;
|
||||||
|
// 1.5x multiplier on raw prediction to account for variance in delivery
|
||||||
|
// times — tight enough to improve on worst-case physics, loose enough
|
||||||
|
// to avoid premature timeouts from model noise.
|
||||||
|
static const double _safetyMargin = 1.5;
|
||||||
|
static const int _minContactObservations = 10;
|
||||||
|
|
||||||
|
List<DeliveryObservation> _observations = [];
|
||||||
|
LinearRegressor? _model;
|
||||||
|
List<String> _activeFeatures = [];
|
||||||
|
int _observationsSinceLastTrain = 0;
|
||||||
|
final Map<String, _ContactStats> _contactStats = {};
|
||||||
|
Timer? _persistTimer;
|
||||||
|
|
||||||
|
TimeoutPredictionService(StorageService storage) : _storage = storage;
|
||||||
|
TimeoutPredictionService.noStorage() : _storage = null;
|
||||||
|
|
||||||
|
int get observationCount => _observations.length;
|
||||||
|
bool get hasModel => _model != null;
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
_observations = await _storage?.loadDeliveryObservations() ?? [];
|
||||||
|
_rebuildContactStats();
|
||||||
|
|
||||||
|
if (_observations.length >= minObservations) {
|
||||||
|
_trainModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: initialized with ${_observations.length} observations, '
|
||||||
|
'model=${_model != null ? "ready" : "waiting for data"}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordObservation({
|
||||||
|
required String contactKey,
|
||||||
|
required int pathLength,
|
||||||
|
required int messageBytes,
|
||||||
|
required int tripTimeMs,
|
||||||
|
int secondsSinceLastRx = 0,
|
||||||
|
}) {
|
||||||
|
final observation = DeliveryObservation(
|
||||||
|
contactKey: contactKey,
|
||||||
|
pathLength: pathLength,
|
||||||
|
messageBytes: messageBytes,
|
||||||
|
secondsSinceLastRx: secondsSinceLastRx,
|
||||||
|
isFlood: pathLength < 0,
|
||||||
|
deliveryMs: tripTimeMs,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
_observations.add(observation);
|
||||||
|
if (_observations.length > maxObservations) {
|
||||||
|
_observations.removeAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
|
||||||
|
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
|
||||||
|
|
||||||
|
_observationsSinceLastTrain++;
|
||||||
|
if (_observationsSinceLastTrain >= _retrainInterval &&
|
||||||
|
_observations.length >= minObservations) {
|
||||||
|
_trainModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistTimer?.cancel();
|
||||||
|
_persistTimer = Timer(const Duration(seconds: 2), () {
|
||||||
|
_storage?.saveDeliveryObservations(_observations);
|
||||||
|
});
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: recorded ${tripTimeMs}ms for $pathLength hops '
|
||||||
|
'(${_observations.length} total)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int? predictTimeout({
|
||||||
|
String? contactKey,
|
||||||
|
required int pathLength,
|
||||||
|
required int messageBytes,
|
||||||
|
int secondsSinceLastRx = 0,
|
||||||
|
}) {
|
||||||
|
if (_model == null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_activeFeatures.isEmpty) return null;
|
||||||
|
|
||||||
|
final allFeatures = {
|
||||||
|
'pathLength': pathLength.toDouble(),
|
||||||
|
'messageBytes': messageBytes.toDouble(),
|
||||||
|
'secSinceRx': secondsSinceLastRx.toDouble(),
|
||||||
|
'isFlood': pathLength < 0 ? 1.0 : 0.0,
|
||||||
|
};
|
||||||
|
final row = _activeFeatures.map((f) => allFeatures[f]!).toList();
|
||||||
|
|
||||||
|
final features = DataFrame(
|
||||||
|
[row],
|
||||||
|
headerExists: false,
|
||||||
|
header: _activeFeatures,
|
||||||
|
);
|
||||||
|
|
||||||
|
final prediction = _model!.predict(features);
|
||||||
|
final rawValue = prediction.rows.first.first;
|
||||||
|
var predictedMs = (rawValue is double)
|
||||||
|
? rawValue
|
||||||
|
: (rawValue as num).toDouble();
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: raw prediction=$predictedMs for '
|
||||||
|
'pathLength=$pathLength, messageBytes=$messageBytes, '
|
||||||
|
'features=$_activeFeatures',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sanity check: if prediction is negative or zero, fall back
|
||||||
|
if (predictedMs <= 0) return null;
|
||||||
|
|
||||||
|
// Blend with per-contact mean if enough data
|
||||||
|
if (contactKey != null) {
|
||||||
|
final stats = _contactStats[contactKey];
|
||||||
|
if (stats != null && stats.count >= _minContactObservations) {
|
||||||
|
predictedMs = 0.5 * predictedMs + 0.5 * stats.mean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector clamps this between physics min/max bounds
|
||||||
|
final timeout = (predictedMs * _safetyMargin).ceil();
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: ML timeout ${timeout}ms '
|
||||||
|
'(raw: ${predictedMs.round()}ms, contact: $contactKey)',
|
||||||
|
);
|
||||||
|
return timeout;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('TimeoutPrediction: prediction failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trainModel() {
|
||||||
|
try {
|
||||||
|
// Build feature columns, then exclude any with zero variance
|
||||||
|
// (ml_algo's OLS produces all-zero coefficients for singular matrices)
|
||||||
|
final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood'];
|
||||||
|
final allExtractors = <double Function(DeliveryObservation)>[
|
||||||
|
(o) => o.pathLength.toDouble(),
|
||||||
|
(o) => o.messageBytes.toDouble(),
|
||||||
|
(o) => o.secondsSinceLastRx.toDouble(),
|
||||||
|
(o) => o.isFlood ? 1.0 : 0.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
_activeFeatures = [];
|
||||||
|
for (var i = 0; i < allNames.length; i++) {
|
||||||
|
final values = _observations.map(allExtractors[i]).toSet();
|
||||||
|
if (values.length > 1) _activeFeatures.add(allNames[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_activeFeatures.isEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: no features with variance, skipping training',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final header = [..._activeFeatures, 'deliveryMs'];
|
||||||
|
final rows = _observations.map((o) {
|
||||||
|
final row = <double>[];
|
||||||
|
for (var i = 0; i < allNames.length; i++) {
|
||||||
|
if (_activeFeatures.contains(allNames[i])) {
|
||||||
|
row.add(allExtractors[i](o));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.add(o.deliveryMs.toDouble());
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
final data = DataFrame([header, ...rows], headerExists: true);
|
||||||
|
|
||||||
|
_model = LinearRegressor(data, 'deliveryMs');
|
||||||
|
_observationsSinceLastTrain = 0;
|
||||||
|
|
||||||
|
// Log training summary with sample predictions
|
||||||
|
final avgMs =
|
||||||
|
_observations.map((o) => o.deliveryMs).reduce((a, b) => a + b) /
|
||||||
|
_observations.length;
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: trained on ${_observations.length} observations '
|
||||||
|
'(avg: ${avgMs.round()}ms, features: $_activeFeatures)',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('TimeoutPrediction: training failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_persistTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rebuildContactStats() {
|
||||||
|
_contactStats.clear();
|
||||||
|
for (final obs in _observations) {
|
||||||
|
_contactStats.putIfAbsent(obs.contactKey, () => _ContactStats());
|
||||||
|
_contactStats[obs.contactKey]!.add(obs.deliveryMs.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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
enum ContactSortOption { lastSeen, recentMessages, name }
|
||||||
|
|
||||||
|
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
export 'contact_filter_types.dart';
|
||||||
|
|
||||||
bool matchesContactQuery(Contact contact, String query) {
|
bool matchesContactQuery(Contact contact, String query) {
|
||||||
final normalizedQuery = query.trim().toLowerCase();
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
if (normalizedQuery.isEmpty) return true;
|
if (normalizedQuery.isEmpty) return true;
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../utils/contact_search.dart';
|
||||||
|
|
||||||
enum ContactSortOption { lastSeen, recentMessages, name }
|
class SortFilterMenuOption<T> {
|
||||||
|
final T value;
|
||||||
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
|
|
||||||
|
|
||||||
class SortFilterMenuOption {
|
|
||||||
final int value;
|
|
||||||
final String label;
|
final String label;
|
||||||
final bool? checked;
|
final bool? checked;
|
||||||
|
|
||||||
@@ -17,16 +14,16 @@ class SortFilterMenuOption {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class SortFilterMenuSection {
|
class SortFilterMenuSection<T> {
|
||||||
final String title;
|
final String title;
|
||||||
final List<SortFilterMenuOption> options;
|
final List<SortFilterMenuOption<T>> options;
|
||||||
|
|
||||||
const SortFilterMenuSection({required this.title, required this.options});
|
const SortFilterMenuSection({required this.title, required this.options});
|
||||||
}
|
}
|
||||||
|
|
||||||
class SortFilterMenu extends StatelessWidget {
|
class SortFilterMenu<T> extends StatelessWidget {
|
||||||
final List<SortFilterMenuSection> sections;
|
final List<SortFilterMenuSection<T>> sections;
|
||||||
final ValueChanged<int> onSelected;
|
final ValueChanged<T> onSelected;
|
||||||
final String tooltip;
|
final String tooltip;
|
||||||
final Widget icon;
|
final Widget icon;
|
||||||
|
|
||||||
@@ -40,7 +37,7 @@ class SortFilterMenu extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PopupMenuButton<int>(
|
return PopupMenuButton<T>(
|
||||||
icon: icon,
|
icon: icon,
|
||||||
tooltip: tooltip,
|
tooltip: tooltip,
|
||||||
onSelected: onSelected,
|
onSelected: onSelected,
|
||||||
@@ -53,11 +50,11 @@ class SortFilterMenu extends StatelessWidget {
|
|||||||
final visibleSections = sections
|
final visibleSections = sections
|
||||||
.where((section) => section.options.isNotEmpty)
|
.where((section) => section.options.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
final entries = <PopupMenuEntry<int>>[];
|
final entries = <PopupMenuEntry<T>>[];
|
||||||
for (int i = 0; i < visibleSections.length; i++) {
|
for (int i = 0; i < visibleSections.length; i++) {
|
||||||
final section = visibleSections[i];
|
final section = visibleSections[i];
|
||||||
entries.add(
|
entries.add(
|
||||||
PopupMenuItem<int>(
|
PopupMenuItem<T>(
|
||||||
enabled: false,
|
enabled: false,
|
||||||
child: Text(section.title, style: labelStyle),
|
child: Text(section.title, style: labelStyle),
|
||||||
),
|
),
|
||||||
@@ -65,14 +62,14 @@ class SortFilterMenu extends StatelessWidget {
|
|||||||
for (final option in section.options) {
|
for (final option in section.options) {
|
||||||
if (option.checked == null) {
|
if (option.checked == null) {
|
||||||
entries.add(
|
entries.add(
|
||||||
PopupMenuItem<int>(
|
PopupMenuItem<T>(
|
||||||
value: option.value,
|
value: option.value,
|
||||||
child: Text(option.label),
|
child: Text(option.label),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
entries.add(
|
entries.add(
|
||||||
CheckedPopupMenuItem<int>(
|
CheckedPopupMenuItem<T>(
|
||||||
value: option.value,
|
value: option.value,
|
||||||
checked: option.checked ?? false,
|
checked: option.checked ?? false,
|
||||||
child: Text(option.label),
|
child: Text(option.label),
|
||||||
@@ -90,16 +87,23 @@ class SortFilterMenu extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const int _actionSortRecentMessages = 1;
|
sealed class _ContactsFilterAction {
|
||||||
const int _actionSortName = 2;
|
const _ContactsFilterAction();
|
||||||
const int _actionSortLastSeen = 3;
|
}
|
||||||
const int _actionFilterAll = 4;
|
|
||||||
const int _actionFilterFavorites = 5;
|
class _SortAction extends _ContactsFilterAction {
|
||||||
const int _actionFilterUsers = 6;
|
final ContactSortOption option;
|
||||||
const int _actionFilterRepeaters = 7;
|
const _SortAction(this.option);
|
||||||
const int _actionFilterRooms = 8;
|
}
|
||||||
const int _actionToggleUnreadOnly = 9;
|
|
||||||
const int _actionNewGroup = 10;
|
class _TypeFilterAction extends _ContactsFilterAction {
|
||||||
|
final ContactTypeFilter filter;
|
||||||
|
const _TypeFilterAction(this.filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ToggleUnreadAction extends _ContactsFilterAction {
|
||||||
|
const _ToggleUnreadAction();
|
||||||
|
}
|
||||||
|
|
||||||
class ContactsFilterMenu extends StatelessWidget {
|
class ContactsFilterMenu extends StatelessWidget {
|
||||||
final ContactSortOption sortOption;
|
final ContactSortOption sortOption;
|
||||||
@@ -108,7 +112,6 @@ class ContactsFilterMenu extends StatelessWidget {
|
|||||||
final ValueChanged<ContactSortOption> onSortChanged;
|
final ValueChanged<ContactSortOption> onSortChanged;
|
||||||
final ValueChanged<ContactTypeFilter> onTypeFilterChanged;
|
final ValueChanged<ContactTypeFilter> onTypeFilterChanged;
|
||||||
final ValueChanged<bool> onUnreadOnlyChanged;
|
final ValueChanged<bool> onUnreadOnlyChanged;
|
||||||
final VoidCallback onNewGroup;
|
|
||||||
|
|
||||||
const ContactsFilterMenu({
|
const ContactsFilterMenu({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -118,30 +121,29 @@ class ContactsFilterMenu extends StatelessWidget {
|
|||||||
required this.onSortChanged,
|
required this.onSortChanged,
|
||||||
required this.onTypeFilterChanged,
|
required this.onTypeFilterChanged,
|
||||||
required this.onUnreadOnlyChanged,
|
required this.onUnreadOnlyChanged,
|
||||||
required this.onNewGroup,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
return SortFilterMenu(
|
return SortFilterMenu<_ContactsFilterAction>(
|
||||||
tooltip: l10n.listFilter_tooltip,
|
tooltip: l10n.listFilter_tooltip,
|
||||||
sections: [
|
sections: [
|
||||||
SortFilterMenuSection(
|
SortFilterMenuSection(
|
||||||
title: l10n.listFilter_sortBy,
|
title: l10n.listFilter_sortBy,
|
||||||
options: [
|
options: [
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionSortRecentMessages,
|
value: _SortAction(ContactSortOption.recentMessages),
|
||||||
label: l10n.listFilter_latestMessages,
|
label: l10n.listFilter_latestMessages,
|
||||||
checked: sortOption == ContactSortOption.recentMessages,
|
checked: sortOption == ContactSortOption.recentMessages,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionSortLastSeen,
|
value: _SortAction(ContactSortOption.lastSeen),
|
||||||
label: l10n.listFilter_heardRecently,
|
label: l10n.listFilter_heardRecently,
|
||||||
checked: sortOption == ContactSortOption.lastSeen,
|
checked: sortOption == ContactSortOption.lastSeen,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionSortName,
|
value: _SortAction(ContactSortOption.name),
|
||||||
label: l10n.listFilter_az,
|
label: l10n.listFilter_az,
|
||||||
checked: sortOption == ContactSortOption.name,
|
checked: sortOption == ContactSortOption.name,
|
||||||
),
|
),
|
||||||
@@ -151,80 +153,66 @@ class ContactsFilterMenu extends StatelessWidget {
|
|||||||
title: l10n.listFilter_filters,
|
title: l10n.listFilter_filters,
|
||||||
options: [
|
options: [
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterAll,
|
value: _TypeFilterAction(ContactTypeFilter.all),
|
||||||
label: l10n.listFilter_all,
|
label: l10n.listFilter_all,
|
||||||
checked: typeFilter == ContactTypeFilter.all,
|
checked: typeFilter == ContactTypeFilter.all,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterFavorites,
|
value: _TypeFilterAction(ContactTypeFilter.favorites),
|
||||||
label: l10n.listFilter_favorites,
|
label: l10n.listFilter_favorites,
|
||||||
checked: typeFilter == ContactTypeFilter.favorites,
|
checked: typeFilter == ContactTypeFilter.favorites,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterUsers,
|
value: _TypeFilterAction(ContactTypeFilter.users),
|
||||||
label: l10n.listFilter_users,
|
label: l10n.listFilter_users,
|
||||||
checked: typeFilter == ContactTypeFilter.users,
|
checked: typeFilter == ContactTypeFilter.users,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterRepeaters,
|
value: _TypeFilterAction(ContactTypeFilter.repeaters),
|
||||||
label: l10n.listFilter_repeaters,
|
label: l10n.listFilter_repeaters,
|
||||||
checked: typeFilter == ContactTypeFilter.repeaters,
|
checked: typeFilter == ContactTypeFilter.repeaters,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterRooms,
|
value: _TypeFilterAction(ContactTypeFilter.rooms),
|
||||||
label: l10n.listFilter_roomServers,
|
label: l10n.listFilter_roomServers,
|
||||||
checked: typeFilter == ContactTypeFilter.rooms,
|
checked: typeFilter == ContactTypeFilter.rooms,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionToggleUnreadOnly,
|
value: const _ToggleUnreadAction(),
|
||||||
label: l10n.listFilter_unreadOnly,
|
label: l10n.listFilter_unreadOnly,
|
||||||
checked: showUnreadOnly,
|
checked: showUnreadOnly,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
|
||||||
value: _actionNewGroup,
|
|
||||||
label: l10n.listFilter_newGroup,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onSelected: (action) {
|
onSelected: (action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case _actionSortRecentMessages:
|
case _SortAction(:final option):
|
||||||
onSortChanged(ContactSortOption.recentMessages);
|
onSortChanged(option);
|
||||||
break;
|
case _TypeFilterAction(:final filter):
|
||||||
case _actionSortName:
|
onTypeFilterChanged(filter);
|
||||||
onSortChanged(ContactSortOption.name);
|
case _ToggleUnreadAction():
|
||||||
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:
|
|
||||||
onUnreadOnlyChanged(!showUnreadOnly);
|
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 {
|
class DiscoveryContactsFilterMenu extends StatelessWidget {
|
||||||
final ContactSortOption sortOption;
|
final ContactSortOption sortOption;
|
||||||
final ContactTypeFilter typeFilter;
|
final ContactTypeFilter typeFilter;
|
||||||
@@ -242,19 +230,19 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
return SortFilterMenu(
|
return SortFilterMenu<_DiscoveryFilterAction>(
|
||||||
tooltip: l10n.listFilter_tooltip,
|
tooltip: l10n.listFilter_tooltip,
|
||||||
sections: [
|
sections: [
|
||||||
SortFilterMenuSection(
|
SortFilterMenuSection(
|
||||||
title: l10n.listFilter_sortBy,
|
title: l10n.listFilter_sortBy,
|
||||||
options: [
|
options: [
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionSortLastSeen,
|
value: _DiscoverySortAction(ContactSortOption.lastSeen),
|
||||||
label: l10n.listFilter_heardRecently,
|
label: l10n.listFilter_heardRecently,
|
||||||
checked: sortOption == ContactSortOption.lastSeen,
|
checked: sortOption == ContactSortOption.lastSeen,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionSortName,
|
value: _DiscoverySortAction(ContactSortOption.name),
|
||||||
label: l10n.listFilter_az,
|
label: l10n.listFilter_az,
|
||||||
checked: sortOption == ContactSortOption.name,
|
checked: sortOption == ContactSortOption.name,
|
||||||
),
|
),
|
||||||
@@ -264,22 +252,22 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
|
|||||||
title: l10n.listFilter_filters,
|
title: l10n.listFilter_filters,
|
||||||
options: [
|
options: [
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterAll,
|
value: _DiscoveryTypeFilterAction(ContactTypeFilter.all),
|
||||||
label: l10n.listFilter_all,
|
label: l10n.listFilter_all,
|
||||||
checked: typeFilter == ContactTypeFilter.all,
|
checked: typeFilter == ContactTypeFilter.all,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterUsers,
|
value: _DiscoveryTypeFilterAction(ContactTypeFilter.users),
|
||||||
label: l10n.listFilter_users,
|
label: l10n.listFilter_users,
|
||||||
checked: typeFilter == ContactTypeFilter.users,
|
checked: typeFilter == ContactTypeFilter.users,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterRepeaters,
|
value: _DiscoveryTypeFilterAction(ContactTypeFilter.repeaters),
|
||||||
label: l10n.listFilter_repeaters,
|
label: l10n.listFilter_repeaters,
|
||||||
checked: typeFilter == ContactTypeFilter.repeaters,
|
checked: typeFilter == ContactTypeFilter.repeaters,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterRooms,
|
value: _DiscoveryTypeFilterAction(ContactTypeFilter.rooms),
|
||||||
label: l10n.listFilter_roomServers,
|
label: l10n.listFilter_roomServers,
|
||||||
checked: typeFilter == ContactTypeFilter.rooms,
|
checked: typeFilter == ContactTypeFilter.rooms,
|
||||||
),
|
),
|
||||||
@@ -288,27 +276,10 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
onSelected: (action) {
|
onSelected: (action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case _actionSortName:
|
case _DiscoverySortAction(:final option):
|
||||||
onSortChanged(ContactSortOption.name);
|
onSortChanged(option);
|
||||||
break;
|
case _DiscoveryTypeFilterAction(:final filter):
|
||||||
case _actionSortLastSeen:
|
onTypeFilterChanged(filter);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
@@ -69,6 +69,8 @@ dependencies:
|
|||||||
material_symbols_icons: ^4.2906.0
|
material_symbols_icons: ^4.2906.0
|
||||||
web: ^1.1.1
|
web: ^1.1.1
|
||||||
flutter_svg: ^2.0.10+1
|
flutter_svg: ^2.0.10+1
|
||||||
|
ml_algo: ^16.0.0
|
||||||
|
ml_dataframe: ^1.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ml_algo/ml_algo.dart';
|
||||||
|
import 'package:ml_dataframe/ml_dataframe.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('LinearRegressor basic sanity check', () {
|
||||||
|
// Simple: y = 2x + 100
|
||||||
|
final data = DataFrame(
|
||||||
|
[
|
||||||
|
[1.0, 102.0],
|
||||||
|
[2.0, 104.0],
|
||||||
|
[3.0, 106.0],
|
||||||
|
[4.0, 108.0],
|
||||||
|
[5.0, 110.0],
|
||||||
|
[10.0, 120.0],
|
||||||
|
[20.0, 140.0],
|
||||||
|
[50.0, 200.0],
|
||||||
|
[0.0, 100.0],
|
||||||
|
[100.0, 300.0],
|
||||||
|
],
|
||||||
|
headerExists: false,
|
||||||
|
header: ['x', 'y'],
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('Training data columns: ${data.header}');
|
||||||
|
debugPrint('Training data rows: ${data.rows.length}');
|
||||||
|
|
||||||
|
final model = LinearRegressor(data, 'y');
|
||||||
|
|
||||||
|
final testDf = DataFrame(
|
||||||
|
[
|
||||||
|
[25.0],
|
||||||
|
],
|
||||||
|
headerExists: false,
|
||||||
|
header: ['x'],
|
||||||
|
);
|
||||||
|
|
||||||
|
final prediction = model.predict(testDf);
|
||||||
|
final value = prediction.rows.first.first;
|
||||||
|
debugPrint('Predict x=25 → y=$value (expected ~150)');
|
||||||
|
expect((value as num).toDouble(), closeTo(150, 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LinearRegressor multi-feature with constant column produces zeros', () {
|
||||||
|
// isFlood=0 for all rows → zero-variance column → singular matrix
|
||||||
|
final data = DataFrame(
|
||||||
|
[
|
||||||
|
[0.0, 50.0, 14.0, 0.0, 1900.0],
|
||||||
|
[0.0, 80.0, 14.0, 0.0, 2200.0],
|
||||||
|
[2.0, 50.0, 14.0, 0.0, 5000.0],
|
||||||
|
[4.0, 50.0, 14.0, 0.0, 9500.0],
|
||||||
|
],
|
||||||
|
headerExists: false,
|
||||||
|
header: [
|
||||||
|
'pathLength',
|
||||||
|
'messageBytes',
|
||||||
|
'hourOfDay',
|
||||||
|
'isFlood',
|
||||||
|
'deliveryMs',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final model = LinearRegressor(data, 'deliveryMs');
|
||||||
|
final testDf = DataFrame(
|
||||||
|
[
|
||||||
|
[2.0, 50.0, 14.0, 0.0],
|
||||||
|
],
|
||||||
|
headerExists: false,
|
||||||
|
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
|
||||||
|
);
|
||||||
|
final pred = model.predict(testDf).rows.first.first;
|
||||||
|
debugPrint(
|
||||||
|
'With constant isFlood column: hops=2 → ${(pred as num).round()}ms (likely 0)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LinearRegressor 2-feature works correctly', () {
|
||||||
|
// Just pathLength + messageBytes → deliveryMs
|
||||||
|
final data = DataFrame(
|
||||||
|
[
|
||||||
|
[0.0, 50.0, 1900.0],
|
||||||
|
[0.0, 80.0, 2200.0],
|
||||||
|
[2.0, 50.0, 5000.0],
|
||||||
|
[2.0, 80.0, 5500.0],
|
||||||
|
[4.0, 50.0, 9500.0],
|
||||||
|
[4.0, 80.0, 10000.0],
|
||||||
|
[0.0, 30.0, 1800.0],
|
||||||
|
[2.0, 30.0, 4800.0],
|
||||||
|
[4.0, 30.0, 9000.0],
|
||||||
|
[0.0, 60.0, 2000.0],
|
||||||
|
],
|
||||||
|
headerExists: false,
|
||||||
|
header: ['pathLength', 'messageBytes', 'deliveryMs'],
|
||||||
|
);
|
||||||
|
|
||||||
|
final model = LinearRegressor(data, 'deliveryMs');
|
||||||
|
|
||||||
|
for (final hops in [0.0, 2.0, 4.0]) {
|
||||||
|
final testDf = DataFrame(
|
||||||
|
[
|
||||||
|
[hops, 50.0],
|
||||||
|
],
|
||||||
|
headerExists: false,
|
||||||
|
header: ['pathLength', 'messageBytes'],
|
||||||
|
);
|
||||||
|
final pred = model.predict(testDf).rows.first.first;
|
||||||
|
debugPrint('2-feature: hops=$hops → ${(pred as num).round()}ms');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LinearRegressor multi-feature with variance in all columns', () {
|
||||||
|
// Mix flood and direct so isFlood has variance
|
||||||
|
final data = DataFrame(
|
||||||
|
[
|
||||||
|
[0.0, 50.0, 14.0, 0.0, 1900.0],
|
||||||
|
[0.0, 80.0, 10.0, 0.0, 2200.0],
|
||||||
|
[2.0, 50.0, 16.0, 0.0, 5000.0],
|
||||||
|
[2.0, 80.0, 20.0, 0.0, 5500.0],
|
||||||
|
[4.0, 50.0, 8.0, 0.0, 9500.0],
|
||||||
|
[4.0, 80.0, 12.0, 0.0, 10000.0],
|
||||||
|
[-1.0, 40.0, 14.0, 1.0, 5000.0],
|
||||||
|
[-1.0, 60.0, 18.0, 1.0, 6500.0],
|
||||||
|
[-1.0, 30.0, 10.0, 1.0, 4000.0],
|
||||||
|
[-1.0, 80.0, 22.0, 1.0, 7000.0],
|
||||||
|
],
|
||||||
|
headerExists: false,
|
||||||
|
header: [
|
||||||
|
'pathLength',
|
||||||
|
'messageBytes',
|
||||||
|
'hourOfDay',
|
||||||
|
'isFlood',
|
||||||
|
'deliveryMs',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final model = LinearRegressor(data, 'deliveryMs');
|
||||||
|
|
||||||
|
for (final tc in [
|
||||||
|
[0.0, 50.0, 14.0, 0.0],
|
||||||
|
[2.0, 50.0, 14.0, 0.0],
|
||||||
|
[4.0, 50.0, 14.0, 0.0],
|
||||||
|
[-1.0, 50.0, 14.0, 1.0],
|
||||||
|
]) {
|
||||||
|
final testDf = DataFrame(
|
||||||
|
[tc],
|
||||||
|
headerExists: false,
|
||||||
|
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
|
||||||
|
);
|
||||||
|
final pred = model.predict(testDf).rows.first.first;
|
||||||
|
debugPrint(
|
||||||
|
'4-feature: hops=${tc[0]} flood=${tc[3]} → ${(pred as num).round()}ms',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/models/delivery_observation.dart';
|
||||||
|
import 'package:meshcore_open/services/timeout_prediction_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late TimeoutPredictionService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
service = TimeoutPredictionService.noStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trains on sample data and predicts sensible timeouts', () {
|
||||||
|
// Simulate realistic delivery data:
|
||||||
|
// Direct 0-hop messages: ~1500-2500ms
|
||||||
|
// 2-hop messages: ~4000-6000ms
|
||||||
|
// 4-hop messages: ~8000-12000ms
|
||||||
|
// Flood messages: ~3000-8000ms
|
||||||
|
final sampleData = [
|
||||||
|
// 0-hop direct
|
||||||
|
_obs(pathLength: 0, messageBytes: 20, deliveryMs: 1800),
|
||||||
|
_obs(pathLength: 0, messageBytes: 50, deliveryMs: 2100),
|
||||||
|
_obs(pathLength: 0, messageBytes: 80, deliveryMs: 2400),
|
||||||
|
_obs(pathLength: 0, messageBytes: 30, deliveryMs: 1925),
|
||||||
|
// 2-hop direct
|
||||||
|
_obs(pathLength: 2, messageBytes: 40, deliveryMs: 4500),
|
||||||
|
_obs(pathLength: 2, messageBytes: 60, deliveryMs: 5200),
|
||||||
|
_obs(pathLength: 2, messageBytes: 25, deliveryMs: 4100),
|
||||||
|
// 4-hop direct
|
||||||
|
_obs(pathLength: 4, messageBytes: 50, deliveryMs: 9800),
|
||||||
|
_obs(pathLength: 4, messageBytes: 30, deliveryMs: 8500),
|
||||||
|
_obs(pathLength: 4, messageBytes: 70, deliveryMs: 10570),
|
||||||
|
// Flood
|
||||||
|
_obs(pathLength: -1, messageBytes: 40, deliveryMs: 5000),
|
||||||
|
_obs(pathLength: -1, messageBytes: 60, deliveryMs: 6500),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Feed all observations
|
||||||
|
for (final obs in sampleData) {
|
||||||
|
service.recordObservation(
|
||||||
|
contactKey: obs.contactKey,
|
||||||
|
pathLength: obs.pathLength,
|
||||||
|
messageBytes: obs.messageBytes,
|
||||||
|
tripTimeMs: obs.deliveryMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(service.hasModel, isTrue);
|
||||||
|
expect(service.observationCount, equals(12));
|
||||||
|
|
||||||
|
// Predict for different scenarios
|
||||||
|
final direct0 = service.predictTimeout(pathLength: 0, messageBytes: 50);
|
||||||
|
final direct2 = service.predictTimeout(pathLength: 2, messageBytes: 50);
|
||||||
|
final direct4 = service.predictTimeout(pathLength: 4, messageBytes: 50);
|
||||||
|
final flood = service.predictTimeout(pathLength: -1, messageBytes: 50);
|
||||||
|
|
||||||
|
// All should return non-null (model is trained)
|
||||||
|
expect(direct0, isNotNull);
|
||||||
|
expect(direct2, isNotNull);
|
||||||
|
expect(direct4, isNotNull);
|
||||||
|
expect(flood, isNotNull);
|
||||||
|
|
||||||
|
// More hops should predict longer timeouts
|
||||||
|
expect(direct4!, greaterThan(direct2!));
|
||||||
|
expect(direct2, greaterThan(direct0!));
|
||||||
|
|
||||||
|
// All should be positive
|
||||||
|
expect(direct0, greaterThan(0));
|
||||||
|
expect(direct4, greaterThan(0));
|
||||||
|
|
||||||
|
// Print predictions for visibility
|
||||||
|
debugPrint('Predictions (with 1.5x safety margin):');
|
||||||
|
debugPrint(' 0-hop direct: ${direct0}ms');
|
||||||
|
debugPrint(' 2-hop direct: ${direct2}ms');
|
||||||
|
debugPrint(' 4-hop direct: ${direct4}ms');
|
||||||
|
debugPrint(' flood: ${flood}ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null before minimum observations', () {
|
||||||
|
for (var i = 0; i < TimeoutPredictionService.minObservations - 1; i++) {
|
||||||
|
service.recordObservation(
|
||||||
|
contactKey: 'abc',
|
||||||
|
pathLength: 0,
|
||||||
|
messageBytes: 50,
|
||||||
|
tripTimeMs: 2000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(service.hasModel, isFalse);
|
||||||
|
expect(service.predictTimeout(pathLength: 0, messageBytes: 50), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caps observations at maxObservations', () {
|
||||||
|
for (var i = 0; i < TimeoutPredictionService.maxObservations + 20; i++) {
|
||||||
|
service.recordObservation(
|
||||||
|
contactKey: 'abc',
|
||||||
|
pathLength: 0,
|
||||||
|
messageBytes: 50,
|
||||||
|
tripTimeMs: 2000 + i,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
service.observationCount,
|
||||||
|
equals(TimeoutPredictionService.maxObservations),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blends per-contact stats after enough observations', () {
|
||||||
|
// Train with mixed contacts and varied features:
|
||||||
|
// contactA is fast (0-hop), contactB is slow (2-hop)
|
||||||
|
for (var i = 0; i < 12; i++) {
|
||||||
|
service.recordObservation(
|
||||||
|
contactKey: 'contactA',
|
||||||
|
pathLength: 0,
|
||||||
|
messageBytes: 30 + i,
|
||||||
|
tripTimeMs: 1500,
|
||||||
|
);
|
||||||
|
service.recordObservation(
|
||||||
|
contactKey: 'contactB',
|
||||||
|
pathLength: 2,
|
||||||
|
messageBytes: 30 + i,
|
||||||
|
tripTimeMs: 8000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final predA = service.predictTimeout(
|
||||||
|
contactKey: 'contactA',
|
||||||
|
pathLength: 0,
|
||||||
|
messageBytes: 50,
|
||||||
|
);
|
||||||
|
final predB = service.predictTimeout(
|
||||||
|
contactKey: 'contactB',
|
||||||
|
pathLength: 0,
|
||||||
|
messageBytes: 50,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(predA, isNotNull);
|
||||||
|
expect(predB, isNotNull);
|
||||||
|
// Contact B (slow) should have a higher predicted timeout than A (fast)
|
||||||
|
expect(predB!, greaterThan(predA!));
|
||||||
|
|
||||||
|
debugPrint('Per-contact blending:');
|
||||||
|
debugPrint(' contactA (fast): ${predA}ms');
|
||||||
|
debugPrint(' contactB (slow): ${predB}ms');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
DeliveryObservation _obs({
|
||||||
|
required int pathLength,
|
||||||
|
required int messageBytes,
|
||||||
|
required int deliveryMs,
|
||||||
|
String contactKey = 'test_contact',
|
||||||
|
}) {
|
||||||
|
return DeliveryObservation(
|
||||||
|
contactKey: contactKey,
|
||||||
|
pathLength: pathLength,
|
||||||
|
messageBytes: messageBytes,
|
||||||
|
secondsSinceLastRx: 5,
|
||||||
|
isFlood: pathLength < 0,
|
||||||
|
deliveryMs: deliveryMs,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user