Compare commits

...

13 Commits

Author SHA1 Message Date
Janez T
ba0f1fc141 feat: Add fast GPS send logs 2026-03-23 21:00:41 +01:00
Janez T
28a9235168 fix: Tame rapid GPS updates #41 2026-03-23 20:51:48 +01:00
Janez T
3e3c9a34d5 fix: Keep received replays separate 2026-03-23 20:07:06 +01:00
Janez T
e85dccea35 feat: Add location-based DM retries 2026-03-23 15:16:59 +01:00
Janez T
019224d955 fix: Hide public channel and show signal pills #123 2026-03-23 15:04:45 +01:00
Janez T
4c048a5a50 fix: Show recipient activity previews 2026-03-23 10:44:20 +01:00
Janez T
2b42e7dd15 feat: Add compass and region support 2026-03-22 19:04:43 +01:00
Janez T
7ea6334f02 fix: Keep composer and sensor sheets closable #123 2026-03-22 18:27:57 +01:00
Janez Troha
6f0a66dc68 Merge pull request #35 from dz0ny/feat/channel-datagrams
feat: Add repeater discovery onboarding flow
2026-03-22 18:06:59 +01:00
Janez T
a26ae96acd feat: Add repeater discovery onboarding flow 2026-03-22 18:06:24 +01:00
Janez Troha
a6dc137e51 Merge pull request #34 from dz0ny/feat/channel-datagrams
feat: Add channel datagrams #1928
2026-03-22 17:08:34 +01:00
Janez Troha
c2501442e3 Merge pull request #33 from dz0ny/feat/update-changelog-with-highlights
Update changelog with weekly highlights and PR links
2026-03-22 17:08:13 +01:00
Janez T
71eac21df9 feat: Add channel datagrams #1928
ref:
2026-03-22 16:46:58 +01:00
59 changed files with 3948 additions and 1885 deletions

View File

@@ -489,7 +489,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 124; CURRENT_PROJECT_VERSION = 128;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 124; CURRENT_PROJECT_VERSION = 128;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 124; CURRENT_PROJECT_VERSION = 128;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 124; CURRENT_PROJECT_VERSION = 128;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 124; CURRENT_PROJECT_VERSION = 128;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 124; CURRENT_PROJECT_VERSION = 128;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>124</string> <string>128</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00024"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.00073">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.144858"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.659743">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="98.142592"> <testcase classname="fastlane.lanes" name="2: build_app" time="105.547745">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="157.12775"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="252.935458">
</testcase> </testcase>

View File

@@ -2285,7 +2285,7 @@
"@change": { "@change": {
"description": "Generic change action" "description": "Generic change action"
}, },
"wizardOverviewDescription": "This app combines MeshCore messaging, SAR field updates, mapping, and device tools in one place.", "wizardOverviewDescription": "This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.",
"@wizardOverviewDescription": { "@wizardOverviewDescription": {
"description": "Updated onboarding overview description" "description": "Updated onboarding overview description"
}, },
@@ -2297,7 +2297,7 @@
"@wizardOverviewFeature2": { "@wizardOverviewFeature2": {
"description": "Updated onboarding overview feature 2" "description": "Updated onboarding overview feature 2"
}, },
"wizardOverviewFeature3": "Connect over BLE or TCP, then manage the companion radio from inside the app.", "wizardOverviewFeature3": "Connect over BLE, WiFi, or Serial, then manage the companion radio from inside the app.",
"@wizardOverviewFeature3": { "@wizardOverviewFeature3": {
"description": "Updated onboarding overview feature 3" "description": "Updated onboarding overview feature 3"
}, },
@@ -2410,11 +2410,11 @@
"@wizardNetworkTitle": { "@wizardNetworkTitle": {
"description": "Updated onboarding network page title" "description": "Updated onboarding network page title"
}, },
"wizardNetworkDescription": "The Contacts tab organizes the network you discover and the routes you learn over time.", "wizardNetworkDescription": "The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.",
"@wizardNetworkDescription": { "@wizardNetworkDescription": {
"description": "Updated onboarding network page description" "description": "Updated onboarding network page description"
}, },
"wizardNetworkFeature1": "Review team members, repeaters, rooms, channels, and pending adverts in one list.", "wizardNetworkFeature1": "Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.",
"@wizardNetworkFeature1": { "@wizardNetworkFeature1": {
"description": "Updated onboarding network feature 1" "description": "Updated onboarding network feature 1"
}, },
@@ -2422,7 +2422,7 @@
"@wizardNetworkFeature2": { "@wizardNetworkFeature2": {
"description": "Updated onboarding network feature 2" "description": "Updated onboarding network feature 2"
}, },
"wizardNetworkFeature3": "Create channels and manage network destinations without leaving the app.", "wizardNetworkFeature3": "Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.",
"@wizardNetworkFeature3": { "@wizardNetworkFeature3": {
"description": "Updated onboarding network feature 3" "description": "Updated onboarding network feature 3"
}, },
@@ -2462,10 +2462,18 @@
"@wizardToolsFeature2": { "@wizardToolsFeature2": {
"description": "Updated onboarding tools feature 2" "description": "Updated onboarding tools feature 2"
}, },
"wizardToolsFeature3": "Use packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.", "wizardToolsFeature3": "Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.",
"@wizardToolsFeature3": { "@wizardToolsFeature3": {
"description": "Updated onboarding tools feature 3" "description": "Updated onboarding tools feature 3"
}, },
"postConnectDiscoveryTitle": "Discover repeaters now?",
"@postConnectDiscoveryTitle": {
"description": "Title for the prompt shown after a successful device connection"
},
"postConnectDiscoveryDescription": "Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.",
"@postConnectDiscoveryDescription": {
"description": "Body text for the prompt shown after a successful device connection"
},
"contactInSensors": "In Sensors", "contactInSensors": "In Sensors",
"@contactInSensors": { "@contactInSensors": {
"description": "Contact actions label when already in sensors" "description": "Contact actions label when already in sensors"
@@ -2770,5 +2778,24 @@
"captured": "Captured", "captured": "Captured",
"size": "Size", "size": "Size",
"noCustomChannelsToClear": "No custom channels to clear.", "noCustomChannelsToClear": "No custom channels to clear.",
"noDeviceContactsToClear": "No device contacts to clear." "noDeviceContactsToClear": "No device contacts to clear.",
"setRegionScope": "Set region scope",
"regionScope": "Region scope",
"regionScopeNone": "None (global)",
"clearRegionScope": "Clear scope",
"regionScopeWarning": "Only repeaters allowing this region will forward.",
"discoverRegions": "Discover from repeaters",
"discoveringRegions": "Discovering regions...",
"enterRegionName": "Region name (e.g. auckland)",
"noRegionsFound": "No regions found on this repeater.",
"regionScopeSet": "Region scope set to {name}",
"@regionScopeSet": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"regionScopeCleared": "Region scope cleared"
} }

View File

@@ -3219,7 +3219,7 @@ abstract class AppLocalizations {
/// Updated onboarding overview description /// Updated onboarding overview description
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'This app combines MeshCore messaging, SAR field updates, mapping, and device tools in one place.'** /// **'This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.'**
String get wizardOverviewDescription; String get wizardOverviewDescription;
/// Updated onboarding overview feature 1 /// Updated onboarding overview feature 1
@@ -3237,7 +3237,7 @@ abstract class AppLocalizations {
/// Updated onboarding overview feature 3 /// Updated onboarding overview feature 3
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Connect over BLE or TCP, then manage the companion radio from inside the app.'** /// **'Connect over BLE, WiFi, or Serial, then manage the companion radio from inside the app.'**
String get wizardOverviewFeature3; String get wizardOverviewFeature3;
/// Updated onboarding messaging page title /// Updated onboarding messaging page title
@@ -3387,13 +3387,13 @@ abstract class AppLocalizations {
/// Updated onboarding network page description /// Updated onboarding network page description
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'The Contacts tab organizes the network you discover and the routes you learn over time.'** /// **'The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.'**
String get wizardNetworkDescription; String get wizardNetworkDescription;
/// Updated onboarding network feature 1 /// Updated onboarding network feature 1
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Review team members, repeaters, rooms, channels, and pending adverts in one list.'** /// **'Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.'**
String get wizardNetworkFeature1; String get wizardNetworkFeature1;
/// Updated onboarding network feature 2 /// Updated onboarding network feature 2
@@ -3405,7 +3405,7 @@ abstract class AppLocalizations {
/// Updated onboarding network feature 3 /// Updated onboarding network feature 3
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Create channels and manage network destinations without leaving the app.'** /// **'Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.'**
String get wizardNetworkFeature3; String get wizardNetworkFeature3;
/// Updated onboarding map page title /// Updated onboarding map page title
@@ -3465,9 +3465,21 @@ abstract class AppLocalizations {
/// Updated onboarding tools feature 3 /// Updated onboarding tools feature 3
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Use packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.'** /// **'Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.'**
String get wizardToolsFeature3; String get wizardToolsFeature3;
/// Title for the prompt shown after a successful device connection
///
/// In en, this message translates to:
/// **'Discover repeaters now?'**
String get postConnectDiscoveryTitle;
/// Body text for the prompt shown after a successful device connection
///
/// In en, this message translates to:
/// **'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'**
String get postConnectDiscoveryDescription;
/// Contact actions label when already in sensors /// Contact actions label when already in sensors
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -4997,6 +5009,72 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'No device contacts to clear.'** /// **'No device contacts to clear.'**
String get noDeviceContactsToClear; String get noDeviceContactsToClear;
/// No description provided for @setRegionScope.
///
/// In en, this message translates to:
/// **'Set region scope'**
String get setRegionScope;
/// No description provided for @regionScope.
///
/// In en, this message translates to:
/// **'Region scope'**
String get regionScope;
/// No description provided for @regionScopeNone.
///
/// In en, this message translates to:
/// **'None (global)'**
String get regionScopeNone;
/// No description provided for @clearRegionScope.
///
/// In en, this message translates to:
/// **'Clear scope'**
String get clearRegionScope;
/// No description provided for @regionScopeWarning.
///
/// In en, this message translates to:
/// **'Only repeaters allowing this region will forward.'**
String get regionScopeWarning;
/// No description provided for @discoverRegions.
///
/// In en, this message translates to:
/// **'Discover from repeaters'**
String get discoverRegions;
/// No description provided for @discoveringRegions.
///
/// In en, this message translates to:
/// **'Discovering regions...'**
String get discoveringRegions;
/// No description provided for @enterRegionName.
///
/// In en, this message translates to:
/// **'Region name (e.g. auckland)'**
String get enterRegionName;
/// No description provided for @noRegionsFound.
///
/// In en, this message translates to:
/// **'No regions found on this repeater.'**
String get noRegionsFound;
/// No description provided for @regionScopeSet.
///
/// In en, this message translates to:
/// **'Region scope set to {name}'**
String regionScopeSet(String name);
/// No description provided for @regionScopeCleared.
///
/// In en, this message translates to:
/// **'Region scope cleared'**
String get regionScopeCleared;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View File

@@ -1898,6 +1898,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Nutze Paketprotokolle, Spektrumsuche und Entwicklerdiagnosen zur Fehlersuche im Mesh.'; 'Nutze Paketprotokolle, Spektrumsuche und Entwicklerdiagnosen zur Fehlersuche im Mesh.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'In Sensoren'; String get contactInSensors => 'In Sensoren';
@@ -2698,4 +2705,40 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => 'Keine Gerätekontakte zum Löschen.'; String get noDeviceContactsToClear => 'Keine Gerätekontakte zum Löschen.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1905,6 +1905,13 @@ class AppLocalizationsEl extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Χρησιμοποιήστε καταγραφές πακέτων, σάρωση φάσματος και διαγνωστικά προγραμματιστή κατά την αντιμετώπιση προβλημάτων του mesh.'; 'Χρησιμοποιήστε καταγραφές πακέτων, σάρωση φάσματος και διαγνωστικά προγραμματιστή κατά την αντιμετώπιση προβλημάτων του mesh.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'Στους αισθητήρες'; String get contactInSensors => 'Στους αισθητήρες';
@@ -2700,4 +2707,40 @@ class AppLocalizationsEl extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => 'Δεν υπάρχουν επαφές συσκευής.'; String get noDeviceContactsToClear => 'Δεν υπάρχουν επαφές συσκευής.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1717,7 +1717,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardOverviewDescription => String get wizardOverviewDescription =>
'This app combines MeshCore messaging, SAR field updates, mapping, and device tools in one place.'; 'This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.';
@override @override
String get wizardOverviewFeature1 => String get wizardOverviewFeature1 =>
@@ -1729,7 +1729,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardOverviewFeature3 => String get wizardOverviewFeature3 =>
'Connect over BLE or TCP, then manage the companion radio from inside the app.'; 'Connect over BLE, WiFi, or Serial, then manage the companion radio from inside the app.';
@override @override
String get wizardMessagingTitle => 'Messaging and Field Reports'; String get wizardMessagingTitle => 'Messaging and Field Reports';
@@ -1819,11 +1819,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardNetworkDescription => String get wizardNetworkDescription =>
'The Contacts tab organizes the network you discover and the routes you learn over time.'; 'The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.';
@override @override
String get wizardNetworkFeature1 => String get wizardNetworkFeature1 =>
'Review team members, repeaters, rooms, channels, and pending adverts in one list.'; 'Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.';
@override @override
String get wizardNetworkFeature2 => String get wizardNetworkFeature2 =>
@@ -1831,7 +1831,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardNetworkFeature3 => String get wizardNetworkFeature3 =>
'Create channels and manage network destinations without leaving the app.'; 'Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.';
@override @override
String get wizardMapOpsTitle => 'Map, Trails, and Shared Geometry'; String get wizardMapOpsTitle => 'Map, Trails, and Shared Geometry';
@@ -1869,7 +1869,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Use packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.'; 'Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'In Sensors'; String get contactInSensors => 'In Sensors';
@@ -2662,4 +2669,40 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => 'No device contacts to clear.'; String get noDeviceContactsToClear => 'No device contacts to clear.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1901,6 +1901,13 @@ class AppLocalizationsEs extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Usa registros de paquetes, escaneo de espectro y diagnósticos de desarrollador al solucionar problemas de la malla.'; 'Usa registros de paquetes, escaneo de espectro y diagnósticos de desarrollador al solucionar problemas de la malla.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'En Sensores'; String get contactInSensors => 'En Sensores';
@@ -2702,4 +2709,40 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => String get noDeviceContactsToClear =>
'No hay contactos del dispositivo que borrar.'; 'No hay contactos del dispositivo que borrar.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1905,6 +1905,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Utilisez les journaux de paquets, le balayage du spectre et les diagnostics développeur pour dépanner le réseau maillé.'; 'Utilisez les journaux de paquets, le balayage du spectre et les diagnostics développeur pour dépanner le réseau maillé.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'Dans Capteurs'; String get contactInSensors => 'Dans Capteurs';
@@ -2706,4 +2713,40 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => String get noDeviceContactsToClear =>
'Aucun contact de l\'appareil à effacer.'; 'Aucun contact de l\'appareil à effacer.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1881,6 +1881,13 @@ class AppLocalizationsHr extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Koristite zapisnike paketa, skeniranje spektra i razvojnu dijagnostiku pri rješavanju problema s mesh mrežom.'; 'Koristite zapisnike paketa, skeniranje spektra i razvojnu dijagnostiku pri rješavanju problema s mesh mrežom.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'U Senzorima'; String get contactInSensors => 'U Senzorima';
@@ -2675,4 +2682,40 @@ class AppLocalizationsHr extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => 'Nema kontakata uređaja za brisanje.'; String get noDeviceContactsToClear => 'Nema kontakata uređaja za brisanje.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1896,6 +1896,13 @@ class AppLocalizationsIt extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Usa log dei pacchetti, scansione dello spettro e diagnostica sviluppatore per risolvere problemi della mesh.'; 'Usa log dei pacchetti, scansione dello spettro e diagnostica sviluppatore per risolvere problemi della mesh.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'Nei Sensori'; String get contactInSensors => 'Nei Sensori';
@@ -2692,4 +2699,40 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => String get noDeviceContactsToClear =>
'Nessun contatto dispositivo da cancellare.'; 'Nessun contatto dispositivo da cancellare.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1893,6 +1893,13 @@ class AppLocalizationsPl extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Używaj logów pakietów, skanowania widma i diagnostyki deweloperskiej podczas rozwiązywania problemów z mesh.'; 'Używaj logów pakietów, skanowania widma i diagnostyki deweloperskiej podczas rozwiązywania problemów z mesh.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'W Czujnikach'; String get contactInSensors => 'W Czujnikach';
@@ -2689,4 +2696,40 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => String get noDeviceContactsToClear =>
'Brak kontaktów urządzenia do wyczyszczenia.'; 'Brak kontaktów urządzenia do wyczyszczenia.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1900,6 +1900,13 @@ class AppLocalizationsPt extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Use logs de pacotes, varredura de espectro e diagnóstico de desenvolvedor ao solucionar problemas da malha.'; 'Use logs de pacotes, varredura de espectro e diagnóstico de desenvolvedor ao solucionar problemas da malha.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'Nos Sensores'; String get contactInSensors => 'Nos Sensores';
@@ -2700,4 +2707,40 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => String get noDeviceContactsToClear =>
'Sem contactos do dispositivo para limpar.'; 'Sem contactos do dispositivo para limpar.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1890,6 +1890,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Используйте журналы пакетов, сканирование спектра и диагностику разработчика для устранения проблем mesh-сети.'; 'Используйте журналы пакетов, сканирование спектра и диагностику разработчика для устранения проблем mesh-сети.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'В датчиках'; String get contactInSensors => 'В датчиках';
@@ -2685,4 +2692,40 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => 'Нет контактов устройства для очистки.'; String get noDeviceContactsToClear => 'Нет контактов устройства для очистки.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1881,6 +1881,13 @@ class AppLocalizationsSl extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Uporabite dnevnik paketov, pregled spektra in razvojno diagnostiko pri odpravljanju težav v mesh omrežju.'; 'Uporabite dnevnik paketov, pregled spektra in razvojno diagnostiko pri odpravljanju težav v mesh omrežju.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'V senzorjih'; String get contactInSensors => 'V senzorjih';
@@ -2675,4 +2682,40 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => 'Ni stikov naprave za brisanje.'; String get noDeviceContactsToClear => 'Ni stikov naprave za brisanje.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1883,6 +1883,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Mesh sorunlarını giderirken paket günlükleri, spektrum taraması ve geliştirici tanılamalarını kullanın.'; 'Mesh sorunlarını giderirken paket günlükleri, spektrum taraması ve geliştirici tanılamalarını kullanın.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'Sensörlerde'; String get contactInSensors => 'Sensörlerde';
@@ -2673,4 +2680,40 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => 'Temizlenecek cihaz kişisi yok.'; String get noDeviceContactsToClear => 'Temizlenecek cihaz kişisi yok.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1896,6 +1896,13 @@ class AppLocalizationsUk extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Використовуйте журнали пакетів, сканування спектра та діагностику розробника для усунення проблем mesh-мережі.'; 'Використовуйте журнали пакетів, сканування спектра та діагностику розробника для усунення проблем mesh-мережі.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'У датчиках'; String get contactInSensors => 'У датчиках';
@@ -2696,4 +2703,40 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => String get noDeviceContactsToClear =>
'Немає контактів пристрою для очищення.'; 'Немає контактів пристрою для очищення.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1796,6 +1796,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get wizardToolsFeature3 => '在排查 mesh 网络问题时使用数据包日志、频谱扫描和开发者诊断。'; String get wizardToolsFeature3 => '在排查 mesh 网络问题时使用数据包日志、频谱扫描和开发者诊断。';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => '在传感器中'; String get contactInSensors => '在传感器中';
@@ -2568,4 +2575,40 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => '没有设备联系人可清除。'; String get noDeviceContactsToClear => '没有设备联系人可清除。';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1 +1,209 @@
{} {
"de": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"el": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"es": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"fr": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"hr": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"it": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"pl": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"pt": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"ru": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"sl": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"tr": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"uk": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"zh": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
]
}

View File

@@ -9,6 +9,11 @@ class PathRecord {
final int failureCount; final int failureCount;
final int lastRoundTripTimeMs; final int lastRoundTripTimeMs;
final DateTime lastUsedAt; final DateTime lastUsedAt;
final DateTime? lastSucceededAt;
final double? senderLatitude;
final double? senderLongitude;
final double? recipientLatitude;
final double? recipientLongitude;
const PathRecord({ const PathRecord({
required this.pathBytes, required this.pathBytes,
@@ -19,6 +24,11 @@ class PathRecord {
required this.failureCount, required this.failureCount,
required this.lastRoundTripTimeMs, required this.lastRoundTripTimeMs,
required this.lastUsedAt, required this.lastUsedAt,
required this.lastSucceededAt,
required this.senderLatitude,
required this.senderLongitude,
required this.recipientLatitude,
required this.recipientLongitude,
}); });
String get signature => String get signature =>
@@ -36,6 +46,11 @@ class PathRecord {
int? failureCount, int? failureCount,
int? lastRoundTripTimeMs, int? lastRoundTripTimeMs,
DateTime? lastUsedAt, DateTime? lastUsedAt,
DateTime? lastSucceededAt,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) { }) {
return PathRecord( return PathRecord(
pathBytes: pathBytes ?? this.pathBytes, pathBytes: pathBytes ?? this.pathBytes,
@@ -46,6 +61,11 @@ class PathRecord {
failureCount: failureCount ?? this.failureCount, failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs, lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
lastUsedAt: lastUsedAt ?? this.lastUsedAt, lastUsedAt: lastUsedAt ?? this.lastUsedAt,
lastSucceededAt: lastSucceededAt ?? this.lastSucceededAt,
senderLatitude: senderLatitude ?? this.senderLatitude,
senderLongitude: senderLongitude ?? this.senderLongitude,
recipientLatitude: recipientLatitude ?? this.recipientLatitude,
recipientLongitude: recipientLongitude ?? this.recipientLongitude,
); );
} }
@@ -59,6 +79,11 @@ class PathRecord {
'failure_count': failureCount, 'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs, 'last_round_trip_time_ms': lastRoundTripTimeMs,
'last_used_at': lastUsedAt.toIso8601String(), 'last_used_at': lastUsedAt.toIso8601String(),
'last_succeeded_at': lastSucceededAt?.toIso8601String(),
'sender_latitude': senderLatitude,
'sender_longitude': senderLongitude,
'recipient_latitude': recipientLatitude,
'recipient_longitude': recipientLongitude,
}; };
} }
@@ -79,6 +104,13 @@ class PathRecord {
lastUsedAt: lastUsedAt:
DateTime.tryParse(json['last_used_at'] as String? ?? '') ?? DateTime.tryParse(json['last_used_at'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0), DateTime.fromMillisecondsSinceEpoch(0),
lastSucceededAt: DateTime.tryParse(
json['last_succeeded_at'] as String? ?? '',
),
senderLatitude: (json['sender_latitude'] as num?)?.toDouble(),
senderLongitude: (json['sender_longitude'] as num?)?.toDouble(),
recipientLatitude: (json['recipient_latitude'] as num?)?.toDouble(),
recipientLongitude: (json['recipient_longitude'] as num?)?.toDouble(),
); );
} }
} }

View File

@@ -2,7 +2,8 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:meshcore_client/meshcore_client.dart' show BufferReader; import 'package:meshcore_client/meshcore_client.dart'
show BufferReader, MeshCoreConstants;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'connection_provider.dart'; import 'connection_provider.dart';
import 'contacts_provider.dart'; import 'contacts_provider.dart';
@@ -211,6 +212,8 @@ class AppProvider with ChangeNotifier {
static const int _maxPacketRetryAttempts = 4; static const int _maxPacketRetryAttempts = 4;
final Map<String, String> _voiceSessionSenderKey6 = {}; final Map<String, String> _voiceSessionSenderKey6 = {};
final Map<String, String> _imageSessionSenderKey6 = {}; final Map<String, String> _imageSessionSenderKey6 = {};
final Map<String, Map<int, VoicePacket>> _pendingChannelVoicePackets = {};
final Map<String, Map<int, ImagePacket>> _pendingChannelImageFragments = {};
final Map<String, Timer> _voiceMissingRetryTimers = {}; final Map<String, Timer> _voiceMissingRetryTimers = {};
final Map<String, int> _voiceMissingRetryAttempts = {}; final Map<String, int> _voiceMissingRetryAttempts = {};
final Map<String, Timer> _imageMissingRetryTimers = {}; final Map<String, Timer> _imageMissingRetryTimers = {};
@@ -1315,6 +1318,7 @@ class AppProvider with ChangeNotifier {
.toLowerCase(); .toLowerCase();
} }
voiceProvider.registerEnvelope(voiceEnvelope); voiceProvider.registerEnvelope(voiceEnvelope);
_replayPendingChannelVoicePackets(voiceEnvelope.sessionId);
enrichedMessage = enrichedMessage.copyWith( enrichedMessage = enrichedMessage.copyWith(
isVoice: true, isVoice: true,
voiceId: voiceEnvelope.sessionId, voiceId: voiceEnvelope.sessionId,
@@ -1352,6 +1356,7 @@ class AppProvider with ChangeNotifier {
.toLowerCase(); .toLowerCase();
} }
imageProvider.registerEnvelope(imageEnvelope); imageProvider.registerEnvelope(imageEnvelope);
_replayPendingChannelImageFragments(imageEnvelope.sessionId);
messagesProvider.addMessage( messagesProvider.addMessage(
enrichedMessage, enrichedMessage,
contactLookup: (name) { contactLookup: (name) {
@@ -1469,15 +1474,7 @@ class AppProvider with ChangeNotifier {
return; return;
} }
final fastGpsPacket = FastGpsPacket.tryParseBinary(payload); if (_handleFastGpsPayload(payload)) {
if (fastGpsPacket != null) {
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
if (sender != null) {
contactsProvider.updateFastGps(
sender.publicKey.sublist(0, 6),
fastGpsPacket,
);
}
return; return;
} }
@@ -1614,66 +1611,44 @@ class AppProvider with ChangeNotifier {
return; return;
} }
if (ImagePacket.isImageBinary(payload)) { if (_handleIncomingImageBinaryPayload(payload)) {
final frag = ImagePacket.tryParseBinary(payload);
if (frag == null) return;
debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
final session = imageProvider.session(frag.sessionId);
if (session == null && frag.total < 1) {
debugPrint(
'⚠️ [AppProvider] Dropping compact image fragment without envelope '
'for session ${frag.sessionId}',
);
return;
}
imageProvider.addFragment(
session == null
? frag
: ImagePacket(
sessionId: frag.sessionId,
format: session.format,
index: frag.index,
total: session.total,
data: frag.data,
),
width: session?.width ?? 0,
height: session?.height ?? 0,
);
_scheduleImageMissingRetry(
frag.sessionId,
justComplete: imageProvider.isComplete(frag.sessionId),
);
return; return;
} }
_handleIncomingVoiceBinaryPayload(payload);
if (!VoicePacket.isVoiceBinary(payload)) return;
final pkt = VoicePacket.tryParseBinary(payload);
if (pkt == null) return;
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
final session = voiceProvider.session(pkt.sessionId);
if (session == null && pkt.total < 1) {
debugPrint(
'⚠️ [AppProvider] Dropping compact voice packet without envelope '
'for session ${pkt.sessionId}',
);
return;
}
final justComplete = voiceProvider.addPacket(
session == null
? pkt
: VoicePacket(
sessionId: pkt.sessionId,
mode: session.mode,
index: pkt.index,
total: session.total,
codec2Data: pkt.codec2Data,
),
);
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
// Insert or update the placeholder message in the chat list
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
}; };
connectionProvider.onChannelDataReceived =
(channelIdx, pathLen, dataType, payload, snrRaw, rssiDbm) {
if (dataType != MeshCoreConstants.dataTypeDev) {
debugPrint(
'📦 [AppProvider] Ignoring channel datagram type '
'0x${dataType.toRadixString(16).padLeft(4, '0')} on channel $channelIdx',
);
return;
}
if (_handleFastGpsPayload(payload)) {
return;
}
if (_handleIncomingImageBinaryPayload(
payload,
allowPreEnvelopeBuffer: true,
)) {
return;
}
if (_handleIncomingVoiceBinaryPayload(
payload,
allowPreEnvelopeBuffer: true,
)) {
return;
}
debugPrint(
'📦 [AppProvider] Unknown developer channel payload on channel '
'$channelIdx: ${payload.isNotEmpty ? payload.first : -1}',
);
};
connectionProvider.onControlDataReceived = connectionProvider.onControlDataReceived =
(payload, snrRaw, rssiDbm, pathLen) { (payload, snrRaw, rssiDbm, pathLen) {
_handleControlDataDiscovery( _handleControlDataDiscovery(
@@ -1786,6 +1761,7 @@ class AppProvider with ChangeNotifier {
return _prepareDirectMessageSend( return _prepareDirectMessageSend(
messageId: messageId, messageId: messageId,
contact: contact, contact: contact,
retryAttempt: retryAttempt,
); );
}; };
@@ -1855,23 +1831,47 @@ class AppProvider with ChangeNotifier {
Future<Contact> _prepareDirectMessageSend({ Future<Contact> _prepareDirectMessageSend({
required String messageId, required String messageId,
required Contact contact, required Contact contact,
required int retryAttempt,
}) async { }) async {
final latestContact = final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact; contactsProvider.findContactByKey(contact.publicKey) ?? contact;
var session = _directMessageRouteSessions[messageId]; var session = _directMessageRouteSessions[messageId];
if (session == null) { if (session == null) {
final selection = await _pathHistoryService.getSelectionForContact( final selection = latestContact.routeHasPath && latestContact.routeHopCount > 0
latestContact, ? PathSelection(
autoRouteRotationEnabled: _autoRouteRotationEnabled, mode: PathSelectionMode.directCurrent,
); pathBytes: Uint8List.fromList(latestContact.routePathBytes),
hopCount: latestContact.routeHopCount,
hashSize: latestContact.routeHashSize,
)
: await _pathHistoryService.getSelectionForContact(
latestContact,
autoRouteRotationEnabled: _autoRouteRotationEnabled,
);
session = _DirectMessageRouteSession( session = _DirectMessageRouteSession(
currentSelection: selection, currentSelection: selection,
originalRoute: ContactRouteCodec.fromContact(latestContact), originalRoute: ContactRouteCodec.fromContact(latestContact),
routerFallbackAttempted: false, routerFallbackAttempted: false,
); );
_directMessageRouteSessions[messageId] = session;
} }
if (!session.routerFallbackAttempted) {
final currentSignature =
latestContact.routeHasPath && latestContact.routeHopCount > 0
? latestContact.routePathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
: null;
final selection = await _resolveDirectMessageSelectionForRetry(
latestContact,
retryAttempt: retryAttempt,
currentSignature: currentSignature,
fallbackSelection: session.currentSelection,
);
session = session.copyWith(currentSelection: selection);
}
_directMessageRouteSessions[messageId] = session;
await _applyPathSelection( await _applyPathSelection(
latestContact, latestContact,
session.currentSelection, session.currentSelection,
@@ -1882,6 +1882,43 @@ class AppProvider with ChangeNotifier {
latestContact; latestContact;
} }
Future<PathSelection> _resolveDirectMessageSelectionForRetry(
Contact contact, {
required int retryAttempt,
required String? currentSignature,
required PathSelection fallbackSelection,
}) async {
if (contact.routeHasPath && contact.routeHopCount > 0 && retryAttempt <= 1) {
return PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(contact.routePathBytes),
hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize,
);
}
if (retryAttempt == 2) {
return PathSelection.flood();
}
if (retryAttempt >= 3) {
final historicalSelection = await _pathHistoryService
.getLastSuccessfulDirectSelection(
contact,
excludeSignature: currentSignature,
senderLatitude: locationTrackingService.currentPosition?.latitude,
senderLongitude: locationTrackingService.currentPosition?.longitude,
recipientLatitude: contact.displayLocation?.latitude,
recipientLongitude: contact.displayLocation?.longitude,
);
if (historicalSelection != null) {
return historicalSelection;
}
}
return fallbackSelection;
}
Future<void> _applyPathSelection( Future<void> _applyPathSelection(
Contact contact, Contact contact,
PathSelection selection, { PathSelection selection, {
@@ -2056,6 +2093,10 @@ class AppProvider with ChangeNotifier {
session.currentSelection, session.currentSelection,
success: true, success: true,
roundTripTimeMs: roundTripTimeMs, roundTripTimeMs: roundTripTimeMs,
senderLatitude: locationTrackingService.currentPosition?.latitude,
senderLongitude: locationTrackingService.currentPosition?.longitude,
recipientLatitude: contact.displayLocation?.latitude,
recipientLongitude: contact.displayLocation?.longitude,
), ),
); );
} }
@@ -2805,6 +2846,23 @@ class AppProvider with ChangeNotifier {
return; return;
} }
final channelIdx = locationTrackingService.fastLocationChannelIdx;
if (channelIdx == null) {
return;
}
final hasTargetChannel = contactsProvider.channels.any(
(channel) =>
(channel.publicKey.length > 1 ? channel.publicKey[1] : 0) ==
channelIdx,
);
if (!hasTargetChannel) {
debugPrint(
'⚠️ [AppProvider] Fast GPS target channel $channelIdx is unavailable',
);
return;
}
final publicKey = connectionProvider.deviceInfo.publicKey; final publicKey = connectionProvider.deviceInfo.publicKey;
if (publicKey == null || publicKey.length < 6) { if (publicKey == null || publicKey.length < 6) {
return; return;
@@ -2821,16 +2879,46 @@ class AppProvider with ChangeNotifier {
timestampSeconds: DateTime.now().millisecondsSinceEpoch ~/ 1000, timestampSeconds: DateTime.now().millisecondsSinceEpoch ~/ 1000,
); );
debugPrint( debugPrint(
'📍 [AppProvider] Sending fast GPS update ($reason): ' '📤 [AppProvider] Fast GPS send '
'${position.latitude}, ${position.longitude}', 'reason=$reason '
'sender=$senderKey6 '
'channel=$channelIdx '
'lat=${position.latitude} '
'lon=${position.longitude} '
'ts=${packet.timestampSeconds}',
); );
try { try {
await connectionProvider.sendRawPrivateMulticast(packet.encodeBinary()); await connectionProvider.sendChannelData(
channelIdx: channelIdx,
dataType: MeshCoreConstants.dataTypeDev,
payload: packet.encodeBinary(),
);
debugPrint(
'✅ [AppProvider] Fast GPS sent '
'sender=$senderKey6 channel=$channelIdx ts=${packet.timestampSeconds}',
);
} catch (e) { } catch (e) {
debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e'); debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e');
} }
} }
Future<bool> sendTestFastLocationUpdate() async {
if (!connectionProvider.deviceInfo.isConnected) {
return false;
}
final position = await locationTrackingService.getCurrentPosition(
timeLimit: const Duration(seconds: 10),
retryCount: 1,
);
if (position == null) {
return false;
}
await _sendFastLocationUpdate(position, reason: 'test');
return true;
}
Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) { Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) {
final liveContact = _resolveContactByPrefixHex(request.requesterKey6); final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
if (liveContact != null) { if (liveContact != null) {
@@ -3363,6 +3451,173 @@ class AppProvider with ChangeNotifier {
}); });
} }
bool _handleFastGpsPayload(Uint8List payload) {
final fastGpsPacket = FastGpsPacket.tryParseBinary(payload);
if (fastGpsPacket == null) {
return false;
}
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
if (sender != null) {
contactsProvider.updateFastGps(
sender.publicKey.sublist(0, 6),
fastGpsPacket,
);
}
return true;
}
bool _handleIncomingImageBinaryPayload(
Uint8List payload, {
bool allowPreEnvelopeBuffer = false,
}) {
if (!ImagePacket.isImageBinary(payload)) {
return false;
}
final frag = ImagePacket.tryParseBinary(payload);
if (frag == null) {
return true;
}
debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
final session = imageProvider.session(frag.sessionId);
if (session == null && frag.total < 1) {
if (allowPreEnvelopeBuffer) {
_pendingChannelImageFragments.putIfAbsent(
frag.sessionId,
() => <int, ImagePacket>{},
)[frag.index] = frag;
debugPrint(
'📷 [AppProvider] Buffered compact image fragment before envelope '
'for session ${frag.sessionId} index=${frag.index}',
);
} else {
debugPrint(
'⚠️ [AppProvider] Dropping compact image fragment without envelope '
'for session ${frag.sessionId}',
);
}
return true;
}
imageProvider.addFragment(
session == null
? frag
: ImagePacket(
sessionId: frag.sessionId,
format: session.format,
index: frag.index,
total: session.total,
data: frag.data,
),
width: session?.width ?? 0,
height: session?.height ?? 0,
);
_scheduleImageMissingRetry(
frag.sessionId,
justComplete: imageProvider.isComplete(frag.sessionId),
);
return true;
}
bool _handleIncomingVoiceBinaryPayload(
Uint8List payload, {
bool allowPreEnvelopeBuffer = false,
}) {
if (!VoicePacket.isVoiceBinary(payload)) {
return false;
}
final pkt = VoicePacket.tryParseBinary(payload);
if (pkt == null) {
return true;
}
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
final session = voiceProvider.session(pkt.sessionId);
if (session == null && pkt.total < 1) {
if (allowPreEnvelopeBuffer) {
_pendingChannelVoicePackets.putIfAbsent(
pkt.sessionId,
() => <int, VoicePacket>{},
)[pkt.index] = pkt;
debugPrint(
'🎙️ [AppProvider] Buffered compact voice packet before envelope '
'for session ${pkt.sessionId} index=${pkt.index}',
);
} else {
debugPrint(
'⚠️ [AppProvider] Dropping compact voice packet without envelope '
'for session ${pkt.sessionId}',
);
}
return true;
}
final normalizedPacket = session == null
? pkt
: VoicePacket(
sessionId: pkt.sessionId,
mode: session.mode,
index: pkt.index,
total: session.total,
codec2Data: pkt.codec2Data,
);
final justComplete = voiceProvider.addPacket(normalizedPacket);
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
_handleIncomingVoicePacket(normalizedPacket, justComplete: justComplete);
return true;
}
void _replayPendingChannelVoicePackets(String sessionId) {
final pending = _pendingChannelVoicePackets.remove(sessionId);
final session = voiceProvider.session(sessionId);
if (pending == null || pending.isEmpty || session == null) {
return;
}
final indices = pending.keys.toList()..sort();
for (final index in indices) {
final packet = pending[index]!;
final normalizedPacket = VoicePacket(
sessionId: packet.sessionId,
mode: session.mode,
index: packet.index,
total: session.total,
codec2Data: packet.codec2Data,
);
final justComplete = voiceProvider.addPacket(normalizedPacket);
_scheduleVoiceMissingRetry(sessionId, justComplete: justComplete);
_handleIncomingVoicePacket(normalizedPacket, justComplete: justComplete);
}
}
void _replayPendingChannelImageFragments(String sessionId) {
final pending = _pendingChannelImageFragments.remove(sessionId);
final session = imageProvider.session(sessionId);
if (pending == null || pending.isEmpty || session == null) {
return;
}
final indices = pending.keys.toList()..sort();
for (final index in indices) {
final fragment = pending[index]!;
final normalizedFragment = ImagePacket(
sessionId: fragment.sessionId,
format: session.format,
index: fragment.index,
total: session.total,
data: fragment.data,
);
imageProvider.addFragment(
normalizedFragment,
width: session.width,
height: session.height,
);
_scheduleImageMissingRetry(
sessionId,
justComplete: imageProvider.isComplete(sessionId),
);
}
}
/// Insert or update a voice placeholder message for binary raw-data packets. /// Insert or update a voice placeholder message for binary raw-data packets.
/// ///
/// Binary voice packets arrive without a chat message, so we synthesise one /// Binary voice packets arrive without a chat message, so we synthesise one
@@ -3767,6 +4022,8 @@ class AppProvider with ChangeNotifier {
_imageMissingRetryAttempts.clear(); _imageMissingRetryAttempts.clear();
_voiceSessionSenderKey6.clear(); _voiceSessionSenderKey6.clear();
_imageSessionSenderKey6.clear(); _imageSessionSenderKey6.clear();
_pendingChannelVoicePackets.clear();
_pendingChannelImageFragments.clear();
_lowBatteryNotifiedNodeIds.clear(); _lowBatteryNotifiedNodeIds.clear();
notifyListeners(); notifyListeners();
} }
@@ -3876,6 +4133,8 @@ class AppProvider with ChangeNotifier {
for (final timer in _imageMissingRetryTimers.values) { for (final timer in _imageMissingRetryTimers.values) {
timer.cancel(); timer.cancel();
} }
_pendingChannelVoicePackets.clear();
_pendingChannelImageFragments.clear();
super.dispose(); super.dispose();
} }
} }

View File

@@ -105,8 +105,6 @@ class ConnectionProvider with ChangeNotifier {
bool _isScanning = false; bool _isScanning = false;
bool get isScanning => _isScanning; bool get isScanning => _isScanning;
bool _isSpectrumScanActive = false;
bool get isSpectrumScanActive => _isSpectrumScanActive;
final Set<int> _pendingDeletedChannelIndices = <int>{}; final Set<int> _pendingDeletedChannelIndices = <int>{};
String? _error; String? _error;
@@ -212,6 +210,15 @@ class ConnectionProvider with ChangeNotifier {
onMessageEchoDetected; onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived; Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
Function(
int channelIdx,
int pathLen,
int dataType,
Uint8List payload,
int snrRaw,
int? rssiDbm,
)?
onChannelDataReceived;
Function(Uint8List payload, int snrRaw, int rssiDbm, int pathLen)? Function(Uint8List payload, int snrRaw, int rssiDbm, int pathLen)?
onControlDataReceived; onControlDataReceived;
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback; Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
@@ -463,10 +470,6 @@ class ConnectionProvider with ChangeNotifier {
}; };
service.onMessageWaiting = () { service.onMessageWaiting = () {
if (_isSpectrumScanActive) {
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
return;
}
if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) { if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true; _pendingAutomaticMessageSync = true;
debugPrint( debugPrint(
@@ -533,6 +536,16 @@ class ConnectionProvider with ChangeNotifier {
service.onRawDataReceived = (payload, snrRaw, rssiDbm) => service.onRawDataReceived = (payload, snrRaw, rssiDbm) =>
onRawDataReceived?.call(payload, snrRaw, rssiDbm); onRawDataReceived?.call(payload, snrRaw, rssiDbm);
service.onChannelDataReceived =
(channelIdx, pathLen, dataType, payload, snrRaw, rssiDbm) =>
onChannelDataReceived?.call(
channelIdx,
pathLen,
dataType,
payload,
snrRaw,
rssiDbm,
);
service.onControlDataReceived = (payload, snrRaw, rssiDbm, pathLen) => service.onControlDataReceived = (payload, snrRaw, rssiDbm, pathLen) =>
onControlDataReceived?.call(payload, snrRaw, rssiDbm, pathLen); onControlDataReceived?.call(payload, snrRaw, rssiDbm, pathLen);
@@ -548,10 +561,14 @@ class ConnectionProvider with ChangeNotifier {
semanticVersion: deviceInfo['semanticVersion'] as String?, semanticVersion: deviceInfo['semanticVersion'] as String?,
clientRepeat: deviceInfo['clientRepeat'] as bool?, clientRepeat: deviceInfo['clientRepeat'] as bool?,
pathHashMode: deviceInfo['pathHashMode'] as int?, pathHashMode: deviceInfo['pathHashMode'] as int?,
supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?,
spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?,
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
); );
// Clear any stale flood scope on connect (firmware v8+)
final fwVer = deviceInfo['firmwareVersion'] as int?;
if (fwVer != null && fwVer >= 8) {
clearFloodScope();
}
notifyListeners(); notifyListeners();
}; };
@@ -1724,6 +1741,7 @@ class ConnectionProvider with ChangeNotifier {
required int channelIdx, required int channelIdx,
required String text, required String text,
String? messageId, String? messageId,
Uint8List? floodScopeKey,
}) async { }) async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
@@ -1736,10 +1754,12 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Channel: $channelIdx'); debugPrint(' Channel: $channelIdx');
debugPrint(' Text: $text'); debugPrint(' Text: $text');
debugPrint(' MessageID: $messageId'); debugPrint(' MessageID: $messageId');
debugPrint(' FloodScope: ${floodScopeKey != null ? "set (${floodScopeKey.length}B)" : "none"}');
await _activeService.sendChannelMessage( await _activeService.sendChannelMessage(
channelIdx: channelIdx, channelIdx: channelIdx,
text: text, text: text,
floodScopeKey: floodScopeKey,
); );
debugPrint('✅ [ConnectionProvider] BLE send completed'); debugPrint('✅ [ConnectionProvider] BLE send completed');
@@ -1804,6 +1824,42 @@ class ConnectionProvider with ChangeNotifier {
); );
} }
/// Clear the flood scope on the device (firmware v8+).
Future<void> clearFloodScope() async {
if (!_activeService.isConnected) return;
try {
await _activeService.clearFloodScope();
} catch (e) {
debugPrint('⚠️ [ConnectionProvider] Failed to clear flood scope: $e');
}
}
Future<void> sendChannelData({
required int channelIdx,
required int dataType,
required Uint8List payload,
Uint8List? floodScopeKey,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.sendChannelData(
channelIdx: channelIdx,
dataType: dataType,
payload: payload,
floodScopeKey: floodScopeKey,
);
} catch (e) {
_error = 'Failed to send channel data: $e';
notifyListeners();
rethrow;
}
}
/// Request telemetry from contact /// Request telemetry from contact
/// ///
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39). /// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
@@ -2238,43 +2294,6 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<SpectrumScanResult?> scanSpectrum({
required int startFrequencyKhz,
required int stopFrequencyKhz,
required int bandwidthKhz,
required int stepKhz,
required int dwellMs,
required int thresholdDb,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return null;
}
try {
_isSpectrumScanActive = true;
_activeService.setSpectrumScanActive(true);
notifyListeners();
return await _activeService.scanSpectrum(
startFrequencyKhz: startFrequencyKhz,
stopFrequencyKhz: stopFrequencyKhz,
bandwidthKhz: bandwidthKhz,
stepKhz: stepKhz,
dwellMs: dwellMs,
thresholdDb: thresholdDb,
);
} catch (e) {
_error = 'Failed to scan spectrum: $e';
notifyListeners();
return null;
} finally {
_isSpectrumScanActive = false;
_activeService.setSpectrumScanActive(false);
notifyListeners();
}
}
/// Set transmit power /// Set transmit power
Future<void> setTxPower(int powerDbm) async { Future<void> setTxPower(int powerDbm) async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
@@ -2464,7 +2483,6 @@ class ConnectionProvider with ChangeNotifier {
} }
Future<void> refreshDeviceInfo() async { Future<void> refreshDeviceInfo() async {
if (_isSpectrumScanActive) return;
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
notifyListeners(); notifyListeners();
@@ -2537,7 +2555,6 @@ class ConnectionProvider with ChangeNotifier {
/// Sync messages from device queue /// Sync messages from device queue
/// Call this repeatedly until no more messages are available /// Call this repeatedly until no more messages are available
Future<bool> syncNextMessage() async { Future<bool> syncNextMessage() async {
if (_isSpectrumScanActive) return false;
// Prevent re-entrancy and too-fast triggers // Prevent re-entrancy and too-fast triggers
if (_isSyncingMessages) { if (_isSyncingMessages) {
// Another sync (single or loop) is in progress // Another sync (single or loop) is in progress
@@ -2576,10 +2593,6 @@ class ConnectionProvider with ChangeNotifier {
/// Sync all waiting messages from device /// Sync all waiting messages from device
Future<int> syncAllMessages({bool force = false}) async { Future<int> syncAllMessages({bool force = false}) async {
if (_isSpectrumScanActive) {
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
return 0;
}
if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) { if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true; _pendingAutomaticMessageSync = true;
debugPrint( debugPrint(

View File

@@ -1143,13 +1143,34 @@ class ContactsProvider with ChangeNotifier {
), ),
); );
debugPrint(
'📥 [ContactsProvider] Fast GPS received '
'sender=${packet.senderKey6} '
'contact=${contact.displayName} '
'contactKey=${contact.publicKeyHex} '
'lat=${packet.latitude} '
'lon=${packet.longitude} '
'ts=${packet.timestampSeconds}',
);
final updatedContact = contact.copyWith( final updatedContact = contact.copyWith(
telemetry: updatedTelemetry, telemetry: updatedTelemetry,
lastAdvert: packet.timestampSeconds, lastAdvert: packet.timestampSeconds,
lastMod: packet.timestampSeconds,
advLat: _coordinateToAdvertMicrodegrees(packet.latitude), advLat: _coordinateToAdvertMicrodegrees(packet.latitude),
advLon: _coordinateToAdvertMicrodegrees(packet.longitude), advLon: _coordinateToAdvertMicrodegrees(packet.longitude),
); );
_contacts[contact.publicKeyHex] = updatedContact; _contacts[contact.publicKeyHex] = updatedContact;
_estimatedLocations[contact.publicKeyHex] = LatLng(
packet.latitude,
packet.longitude,
);
debugPrint(
'✅ [ContactsProvider] Fast GPS applied '
'contact=${updatedContact.displayName} '
'contactKey=${updatedContact.publicKeyHex} '
'lastAdvert=${updatedContact.lastAdvert}',
);
_persistContacts(); _persistContacts();
notifyListeners(); notifyListeners();
} }

View File

@@ -21,8 +21,8 @@ class MessageRetryManager {
final Map<String, int> _pathFailureStreaks = {}; final Map<String, int> _pathFailureStreaks = {};
/// Max retry attempts when the contact has a known path. /// Max retry attempts when the contact has a known path.
/// Official MeshCore app uses 5 (with auto-retry) or 3 (without). /// Sequence: 2 direct attempts, flood, then last successful route.
static const int maxRetryAttemptsWithPath = 5; static const int maxRetryAttemptsWithPath = 3;
/// No retries for flood-only contacts (no known path). /// No retries for flood-only contacts (no known path).
/// Value 0 means: don't retry at all, go straight to fallback/fail. /// Value 0 means: don't retry at all, go straight to fallback/fail.

View File

@@ -664,6 +664,11 @@ class MessagesProvider with ChangeNotifier {
return; // Skip duplicate return; // Skip duplicate
} }
final matchingSentReplayIndex = _findMatchingSentReplayIndex(finalMessage);
if (matchingSentReplayIndex != -1) {
_clearChannelSendWarning(_messages[matchingSentReplayIndex].id);
}
_messages.add(finalMessage); _messages.add(finalMessage);
if (contactLocationSnapshot != null) { if (contactLocationSnapshot != null) {
_messageContactLocations[finalMessage.id] = contactLocationSnapshot; _messageContactLocations[finalMessage.id] = contactLocationSnapshot;
@@ -712,7 +717,8 @@ class MessagesProvider with ChangeNotifier {
for (int index = 0; index < _messages.length; index++) { for (int index = 0; index < _messages.length; index++) {
final existing = _messages[index]; final existing = _messages[index];
if (!_matchesDuplicateScope(existing, message) || if (existing.isSentMessage ||
!_matchesDuplicateScope(existing, message) ||
existing.text != message.text) { existing.text != message.text) {
continue; continue;
} }
@@ -790,6 +796,27 @@ class MessagesProvider with ChangeNotifier {
existingSenderName == incomingSenderName; existingSenderName == incomingSenderName;
} }
int _findMatchingSentReplayIndex(Message message) {
if (!message.isChannelMessage || message.isSentMessage) {
return -1;
}
for (int index = 0; index < _messages.length; index++) {
final existing = _messages[index];
if (!existing.isSentMessage ||
!existing.isChannelMessage ||
existing.text != message.text) {
continue;
}
if (_matchesDuplicateScope(existing, message)) {
return index;
}
}
return -1;
}
/// Add multiple messages /// Add multiple messages
void addMessages(List<Message> messages) { void addMessages(List<Message> messages) {
int addedCount = 0; int addedCount = 0;
@@ -2354,17 +2381,6 @@ class MessagesProvider with ChangeNotifier {
return; return;
} }
// On the last attempt, reset the path to force flood mode
// (matches official MeshCore app behaviour)
if (_retryManager.isLastAttempt(currentMessage, contact)) {
debugPrint(
'🔄 [MessagesProvider] Last attempt — resetting path to flood for $messageId',
);
if (resetPathBeforeLastRetryCallback != null) {
await resetPathBeforeLastRetryCallback!(contact);
}
}
if (sendMessageCallback != null) { if (sendMessageCallback != null) {
final queued = await sendMessageCallback!( final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey, contactPublicKey: contact.publicKey,

View File

@@ -19,6 +19,8 @@ import '../utils/avatar_label_helper.dart';
import '../widgets/common/contact_avatar.dart'; import '../widgets/common/contact_avatar.dart';
import '../widgets/contacts/contact_tile.dart'; import '../widgets/contacts/contact_tile.dart';
import '../widgets/contacts/add_channel_dialog.dart'; import '../widgets/contacts/add_channel_dialog.dart';
import '../services/region_scope_preferences.dart';
import '../utils/toast_logger.dart';
import 'add_contact_screen.dart'; import 'add_contact_screen.dart';
class ContactsTab extends StatefulWidget { class ContactsTab extends StatefulWidget {
@@ -546,6 +548,15 @@ class _ContactsTabState extends State<ContactsTab> {
await _exportHashChannelPskBase64(context, channel); await _exportHashChannelPskBase64(context, channel);
}, },
), ),
_ChannelSheetAction(
icon: Icons.language_rounded,
label: l10n.setRegionScope,
onTap: () async {
Navigator.pop(context);
if (!context.mounted) return;
_showRegionScopeForChannel(context, channel);
},
),
if (!channel.isPublicChannel) if (!channel.isPublicChannel)
_ChannelSheetAction( _ChannelSheetAction(
icon: Icons.delete_outline_rounded, icon: Icons.delete_outline_rounded,
@@ -575,6 +586,38 @@ class _ContactsTabState extends State<ContactsTab> {
); );
} }
void _showRegionScopeForChannel(BuildContext context, Contact channel) async {
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final l10n = AppLocalizations.of(context)!;
final currentScope = await RegionScopePreferences.getScope(channelIdx);
if (!context.mounted) return;
showModalBottomSheet(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) {
return _ContactsRegionScopeSheet(
currentScopeName: currentScope?.name,
l10n: l10n,
onScopeSelected: (String? name) async {
Navigator.of(sheetContext).pop();
if (name == null) {
await RegionScopePreferences.clearScope(channelIdx);
if (!context.mounted) return;
ToastLogger.success(context, l10n.regionScopeCleared);
} else {
final key = RegionScopePreferences.deriveRegionKey(name);
await RegionScopePreferences.setScope(channelIdx, name, key);
if (!context.mounted) return;
ToastLogger.success(context, l10n.regionScopeSet(name));
}
},
);
},
);
}
Color _sectionAccentColor(BuildContext context, ContactSection section) { Color _sectionAccentColor(BuildContext context, ContactSection section) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
switch (section) { switch (section) {
@@ -2394,3 +2437,165 @@ class _MetricChip extends StatelessWidget {
); );
} }
} }
class _ContactsRegionScopeSheet extends StatefulWidget {
final String? currentScopeName;
final AppLocalizations l10n;
final ValueChanged<String?> onScopeSelected;
const _ContactsRegionScopeSheet({
required this.currentScopeName,
required this.l10n,
required this.onScopeSelected,
});
@override
State<_ContactsRegionScopeSheet> createState() =>
_ContactsRegionScopeSheetState();
}
class _ContactsRegionScopeSheetState
extends State<_ContactsRegionScopeSheet> {
final TextEditingController _nameController = TextEditingController();
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
void _submitManualName() {
var name = _nameController.text.trim();
if (name.isEmpty) return;
if (!name.startsWith('#')) name = '#$name';
widget.onScopeSelected(name);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final l10n = widget.l10n;
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.72,
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.regionScope,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
l10n.regionScopeWarning,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
_ScopeOption(
label: l10n.regionScopeNone,
isSelected: widget.currentScopeName == null,
onTap: () => widget.onScopeSelected(null),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _nameController,
decoration: InputDecoration(
hintText: l10n.enterRegionName,
isDense: true,
prefixText: '#',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
onSubmitted: (_) => _submitManualName(),
),
),
const SizedBox(width: 8),
FilledButton.tonal(
onPressed: _submitManualName,
child: const Icon(Icons.check_rounded, size: 20),
),
],
),
],
),
),
),
);
}
}
class _ScopeOption extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ScopeOption({
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
isSelected
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
size: 20,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface,
),
),
),
],
),
),
),
);
}
}

View File

@@ -12,7 +12,9 @@ import '../widgets/compact_signal_indicator.dart' show SignalMetric;
enum _DiscoveryListFilter { all, repeaters, sensors, others } enum _DiscoveryListFilter { all, repeaters, sensors, others }
class DiscoveryScreen extends StatefulWidget { class DiscoveryScreen extends StatefulWidget {
const DiscoveryScreen({super.key}); final bool autoDiscoverRepeatersOnOpen;
const DiscoveryScreen({super.key, this.autoDiscoverRepeatersOnOpen = false});
@override @override
State<DiscoveryScreen> createState() => _DiscoveryScreenState(); State<DiscoveryScreen> createState() => _DiscoveryScreenState();
@@ -25,6 +27,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
final Set<int> _runningDiscoveryTypes = <int>{}; final Set<int> _runningDiscoveryTypes = <int>{};
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
bool _isResolvingAll = false; bool _isResolvingAll = false;
bool _hasQueuedAutoRepeaterDiscovery = false;
String _searchQuery = ''; String _searchQuery = '';
_DiscoveryListFilter _selectedFilter = _DiscoveryListFilter.all; _DiscoveryListFilter _selectedFilter = _DiscoveryListFilter.all;
late final Future<List<MeshMapNode>> _cachedNodesFuture; late final Future<List<MeshMapNode>> _cachedNodesFuture;
@@ -35,6 +38,13 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes( _cachedNodesFuture = MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl, cacheTtl: MeshMapNodesService.traceCacheTtl,
); );
if (widget.autoDiscoverRepeatersOnOpen) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_autoDiscoverRepeaters();
});
}
} }
@override @override
@@ -96,9 +106,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
advertType: advertType, advertType: advertType,
); );
if (!mounted) return; if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
final label = switch (advertType) { final label = switch (advertType) {
_repeaterAdvertType => 'Repeater discovery sent', _repeaterAdvertType => l10n.repeaterDiscoverySent,
_sensorAdvertType => 'Sensor discovery sent', _sensorAdvertType => l10n.sensorDiscoverySent,
_ => 'Discovery sent', _ => 'Discovery sent',
}; };
ScaffoldMessenger.of( ScaffoldMessenger.of(
@@ -113,6 +124,14 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
} }
} }
Future<void> _autoDiscoverRepeaters() async {
if (_hasQueuedAutoRepeaterDiscovery) {
return;
}
_hasQueuedAutoRepeaterDiscovery = true;
await _discoverNodeType(_repeaterAdvertType);
}
Future<void> _resolveAdvert(PendingAdvert advert) async { Future<void> _resolveAdvert(PendingAdvert advert) async {
final keyHex = advert.publicKeyHex; final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return; if (_resolvingAdvertKeys.contains(keyHex)) return;

View File

@@ -23,13 +23,11 @@ import 'device_config_screen.dart';
import 'packet_log_screen.dart'; import 'packet_log_screen.dart';
import 'live_traffic_screen.dart'; import 'live_traffic_screen.dart';
import 'profiles_screen.dart'; import 'profiles_screen.dart';
import 'spectrum_scan_screen.dart';
import '../utils/toast_logger.dart'; import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../widgets/permission_request_dialog.dart'; import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart'; import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart'; import '../utils/battery_display_helper.dart';
import '../services/developer_mode_service.dart';
import '../services/mesh_map_nodes_service.dart'; import '../services/mesh_map_nodes_service.dart';
import '../services/profile_device_key_resolver.dart'; import '../services/profile_device_key_resolver.dart';
import '../services/profile_manager.dart'; import '../services/profile_manager.dart';
@@ -68,7 +66,6 @@ class _HomeScreenState extends State<HomeScreen>
int _currentIndex = 0; int _currentIndex = 0;
bool _isMapFullscreen = false; bool _isMapFullscreen = false;
bool _showRxTxIndicators = true; bool _showRxTxIndicators = true;
bool _isDeveloperModeEnabled = false;
bool _isMapEnabled = true; bool _isMapEnabled = true;
bool _isContactsEnabled = true; bool _isContactsEnabled = true;
bool _isSensorsEnabled = false; bool _isSensorsEnabled = false;
@@ -107,7 +104,6 @@ class _HomeScreenState extends State<HomeScreen>
// Initialize synchronously so first build always has a valid controller. // Initialize synchronously so first build always has a valid controller.
_initTabController(); _initTabController();
_loadRxTxPreference(); _loadRxTxPreference();
_loadDeveloperModePreference();
MeshMapNodesService.syncInBackgroundIfStale(); MeshMapNodesService.syncInBackgroundIfStale();
// Show permission dialog after the first frame if needed // Show permission dialog after the first frame if needed
@@ -291,14 +287,6 @@ class _HomeScreenState extends State<HomeScreen>
} }
} }
Future<void> _loadDeveloperModePreference() async {
final isEnabled = await DeveloperModeService.isEnabled();
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = isEnabled;
});
}
void _showPermissionDialog() { void _showPermissionDialog() {
if (!mounted) return; if (!mounted) return;
@@ -749,12 +737,10 @@ class _HomeScreenState extends State<HomeScreen>
} }
} }
void _showConnectionDialog(BuildContext context) { Future<void> _showConnectionDialog(BuildContext context) async {
showModalBottomSheet( await showConnectionDialogFlow(
context: context, context,
isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (context) => const ConnectionDialog(),
); );
} }
@@ -811,32 +797,6 @@ class _HomeScreenState extends State<HomeScreen>
.read<ProfileManager>() .read<ProfileManager>()
.profilesEnabled; .profilesEnabled;
if (_isDeveloperModeEnabled) {
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar),
SizedBox(width: 8),
Text(AppLocalizations.of(context)!.spectrumScan),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) =>
const SpectrumScanScreen(),
),
);
});
},
),
);
}
items.add( items.add(
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
@@ -954,7 +914,6 @@ class _HomeScreenState extends State<HomeScreen>
), ),
); );
_loadRxTxPreference(); _loadRxTxPreference();
_loadDeveloperModePreference();
}); });
}, },
), ),

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -10,6 +11,7 @@ import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:flutter_compass/flutter_compass.dart'; import 'package:flutter_compass/flutter_compass.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../services/compass_support.dart';
import '../utils/slovenian_crs.dart'; import '../utils/slovenian_crs.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../providers/messages_provider.dart'; import '../providers/messages_provider.dart';
@@ -200,46 +202,64 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
} }
void _startCompassTracking() { void _startCompassTracking() {
if (!CompassSupport.isAvailable) {
return;
}
final compassStream = FlutterCompass.events; final compassStream = FlutterCompass.events;
if (compassStream == null) { if (compassStream == null) {
return; return;
} }
// Start listening to compass events // Start listening to compass events
_compassStreamSubscription = compassStream.listen((CompassEvent event) { _compassStreamSubscription = compassStream.listen(
// Check if widget is disposing, mounted, and event has valid heading (CompassEvent event) {
if (_isDisposing || !mounted || event.heading == null) return; // Check if widget is disposing, mounted, and event has valid heading
if (_isDisposing || !mounted || event.heading == null) return;
try { try {
setState(() { setState(() {
_compassHeading = event.heading; _compassHeading = event.heading;
}); });
// Rotate map if rotation mode is enabled and we have compass heading // Rotate map if rotation mode is enabled and we have compass heading
// Only rotate if map is ready // Only rotate if map is ready
if (_rotateMarkerWithHeading && if (_rotateMarkerWithHeading &&
event.heading != null && event.heading != null &&
_isMapReady && _isMapReady &&
!_isDisposing) { !_isDisposing) {
try { try {
// Use moveAndRotate to set absolute rotation // Use moveAndRotate to set absolute rotation
final camera = _mapController.camera; final camera = _mapController.camera;
_mapController.moveAndRotate( _mapController.moveAndRotate(
camera.center, camera.center,
camera.zoom, camera.zoom,
-event.heading!, -event.heading!,
); );
} catch (e) { } catch (e) {
// Map not ready yet, ignore // Map not ready yet, ignore
}
}
} catch (e) {
// Widget disposed during setState, ignore
if (!_isDisposing) {
debugPrint('Compass tracking error: $e');
} }
} }
} catch (e) { },
// Widget disposed during setState, ignore onError: (Object error, StackTrace stackTrace) {
if (!_isDisposing) { if (_isDisposing) {
debugPrint('Compass tracking error: $e'); return;
} }
}
}); if (error is MissingPluginException) {
debugPrint('Compass plugin is unavailable on this platform');
return;
}
debugPrint('Compass tracking stream error: $error');
},
);
} }
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
@@ -1004,7 +1024,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
), ),
child: DetailedCompassDialog( child: DetailedCompassDialog(
initialPosition: _locationService.currentPosition, initialPosition: _locationService.currentPosition,
initialHeading: _currentHeading, initialHeading: _compassHeading,
contacts: contacts, contacts: contacts,
sarMarkers: sarMarkers, sarMarkers: sarMarkers,
), ),
@@ -1030,7 +1050,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
), ),
child: DetailedCompassDialog( child: DetailedCompassDialog(
initialPosition: _locationService.currentPosition, initialPosition: _locationService.currentPosition,
initialHeading: _currentHeading, initialHeading: _compassHeading,
contacts: contacts, contacts: contacts,
sarMarkers: sarMarkers, sarMarkers: sarMarkers,
preSelectedContact: selectedContact, preSelectedContact: selectedContact,

View File

@@ -6,6 +6,7 @@ import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:meshcore_client/meshcore_client.dart' show MeshCoreConstants;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import '../providers/messages_provider.dart'; import '../providers/messages_provider.dart';
@@ -35,6 +36,7 @@ import '../utils/tictactoe_message_parser.dart';
import '../providers/image_provider.dart' as ip; import '../providers/image_provider.dart' as ip;
import '../services/image_codec_service.dart'; import '../services/image_codec_service.dart';
import '../services/image_preferences.dart'; import '../services/image_preferences.dart';
import '../services/region_scope_preferences.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -51,102 +53,78 @@ class MessagesTab extends StatefulWidget {
class _ComposerActionTile extends StatelessWidget { class _ComposerActionTile extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; final String title;
final String subtitle;
final Color color; final Color color;
final bool enabled; final bool enabled;
final bool busy;
final VoidCallback? onTap; final VoidCallback? onTap;
const _ComposerActionTile({ const _ComposerActionTile({
required this.icon, required this.icon,
required this.title, required this.title,
required this.subtitle,
required this.color, required this.color,
this.enabled = true, this.enabled = true,
this.busy = false,
this.onTap, this.onTap,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final effectiveColor = enabled final effectiveColor = enabled
? color ? color
: colorScheme.onSurfaceVariant.withValues(alpha: 0.45); : colorScheme.onSurfaceVariant.withValues(alpha: 0.45);
return Material( return Material(
color: Colors.transparent, color: enabled
child: InkWell( ? colorScheme.surfaceContainerLow
borderRadius: BorderRadius.circular(24), : colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(20),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
enabled: enabled,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
onTap: enabled ? onTap : null, onTap: enabled ? onTap : null,
child: Ink( leading: Container(
width: 42,
height: 42,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: effectiveColor.withValues(alpha: enabled ? 0.14 : 0.10),
begin: Alignment.topLeft, borderRadius: BorderRadius.circular(14),
end: Alignment.bottomRight,
colors: [
enabled
? colorScheme.surfaceContainerLow
: colorScheme.surfaceContainerHighest,
enabled
? effectiveColor.withValues(alpha: 0.08)
: colorScheme.surfaceContainerHigh,
],
),
borderRadius: BorderRadius.circular(24),
border: Border.all( border: Border.all(
color: enabled color: enabled
? effectiveColor.withValues(alpha: 0.16) ? effectiveColor.withValues(alpha: 0.16)
: colorScheme.outlineVariant.withValues(alpha: 0.16), : colorScheme.outlineVariant.withValues(alpha: 0.16),
), ),
boxShadow: [
BoxShadow(
color: effectiveColor.withValues(alpha: enabled ? 0.08 : 0.03),
blurRadius: 14,
offset: const Offset(0, 8),
),
],
), ),
child: Padding( alignment: Alignment.center,
padding: const EdgeInsets.all(12), child: Icon(icon, color: effectiveColor, size: 20),
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, title: Text(
children: [ title,
Container( maxLines: 2,
width: 38, overflow: TextOverflow.ellipsis,
height: 38, style: theme.textTheme.titleSmall?.copyWith(
decoration: BoxDecoration( fontWeight: FontWeight.w700,
color: effectiveColor.withValues(alpha: enabled ? 0.14 : 0.10), letterSpacing: -0.2,
borderRadius: BorderRadius.circular(14), color: enabled
), ? colorScheme.onSurface
alignment: Alignment.center, : colorScheme.onSurfaceVariant,
child: Icon(icon, color: effectiveColor, size: 20),
),
const SizedBox(height: 8),
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: enabled
? colorScheme.onSurface
: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.15,
),
),
],
),
), ),
), ),
trailing: busy
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.2,
color: effectiveColor,
),
)
: Icon(
Icons.chevron_right_rounded,
color: colorScheme.onSurfaceVariant,
),
), ),
); );
} }
@@ -176,6 +154,11 @@ class _MessagesTabState extends State<MessagesTab> {
MessageDestinationPreferences.destinationTypeChannel; MessageDestinationPreferences.destinationTypeChannel;
Contact? _selectedRecipient; Contact? _selectedRecipient;
// Region scope state
String? _channelRegionScopeName;
Uint8List? _channelRegionScopeKey;
Map<int, String> _channelRegionScopes = {};
// Image sending state // Image sending state
bool _isSendingImage = false; bool _isSendingImage = false;
final ImagePicker _imagePicker = ImagePicker(); final ImagePicker _imagePicker = ImagePicker();
@@ -203,6 +186,7 @@ class _MessagesTabState extends State<MessagesTab> {
// Load saved message destination // Load saved message destination
_loadSavedDestination(); _loadSavedDestination();
_loadVoiceSettings(); _loadVoiceSettings();
_loadAllChannelRegionScopes();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForNavigationRequest(); _checkForNavigationRequest();
}); });
@@ -506,6 +490,9 @@ class _MessagesTabState extends State<MessagesTab> {
} }
_enforceMessageByteLimit(); _enforceMessageByteLimit();
// Load region scope for channel destinations
await _loadRegionScope();
} }
/// Show recipient selector bottom sheet /// Show recipient selector bottom sheet
@@ -559,6 +546,7 @@ class _MessagesTabState extends State<MessagesTab> {
currentDestinationType: _destinationType, currentDestinationType: _destinationType,
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex, currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
onSelect: _onRecipientSelected, onSelect: _onRecipientSelected,
channelRegionScopes: _channelRegionScopes,
), ),
); );
} }
@@ -574,6 +562,9 @@ class _MessagesTabState extends State<MessagesTab> {
_enforceMessageByteLimit(); _enforceMessageByteLimit();
// Load region scope for channel destinations
await _loadRegionScope();
// Save to preferences // Save to preferences
await MessageDestinationPreferences.setDestination( await MessageDestinationPreferences.setDestination(
type, type,
@@ -584,6 +575,51 @@ class _MessagesTabState extends State<MessagesTab> {
if (!mounted) return; if (!mounted) return;
} }
Future<void> _loadRegionScope() async {
if (_destinationType !=
MessageDestinationPreferences.destinationTypeChannel) {
if (_channelRegionScopeName != null) {
setState(() {
_channelRegionScopeName = null;
_channelRegionScopeKey = null;
});
}
return;
}
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
final scope = await RegionScopePreferences.getScope(channelIdx);
if (!mounted) return;
setState(() {
_channelRegionScopeName = scope?.name;
_channelRegionScopeKey = scope?.key;
if (scope != null) {
_channelRegionScopes[channelIdx] = scope.name;
} else {
_channelRegionScopes.remove(channelIdx);
}
});
}
Future<void> _loadAllChannelRegionScopes() async {
final contactsProvider = context.read<ContactsProvider>();
final channels = contactsProvider.chatContacts
.where((c) => c.publicKey.length > 1 && c.publicKey[0] == 0)
.toList();
final scopes = <int, String>{};
// Always check channel 0 (public)
final scope0 = await RegionScopePreferences.getScope(0);
if (scope0 != null) scopes[0] = scope0.name;
for (final ch in channels) {
final idx = ch.publicKey[1];
final scope = await RegionScopePreferences.getScope(idx);
if (scope != null) scopes[idx] = scope.name;
}
if (!mounted) return;
setState(() {
_channelRegionScopes = scopes;
});
}
Future<void> _applyPendingDestination({ Future<void> _applyPendingDestination({
required String type, required String type,
String? recipientPublicKeyHex, String? recipientPublicKeyHex,
@@ -850,11 +886,12 @@ class _MessagesTabState extends State<MessagesTab> {
// Add to messages list with "sending" status // Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient); messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
// Send to selected channel // Send to selected channel (with region scope if set)
await connectionProvider.sendChannelMessage( await connectionProvider.sendChannelMessage(
channelIdx: channelIdx, channelIdx: channelIdx,
text: text, text: text,
messageId: messageId, messageId: messageId,
floodScopeKey: _channelRegionScopeKey,
); );
} }
@@ -964,6 +1001,7 @@ class _MessagesTabState extends State<MessagesTab> {
if (!shouldContinue) return; if (!shouldContinue) return;
if (!mounted) return; if (!mounted) return;
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
if (!connectionProvider.deviceInfo.isConnected) { if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device'); ToastLogger.error(context, 'Not connected to device');
return; return;
@@ -975,6 +1013,7 @@ class _MessagesTabState extends State<MessagesTab> {
final rawBytes = await picked.readAsBytes(); final rawBytes = await picked.readAsBytes();
setState(() => _isSendingImage = true); setState(() => _isSendingImage = true);
String? failedMessageId;
try { try {
// Compress AVIF using user-selected size, compression and color mode. // Compress AVIF using user-selected size, compression and color mode.
final maxSize = await ImagePreferences.getMaxSize(); final maxSize = await ImagePreferences.getMaxSize();
@@ -1052,8 +1091,8 @@ class _MessagesTabState extends State<MessagesTab> {
imageProvider.cacheOutgoingSession(sessionId, fragments, envelope); imageProvider.cacheOutgoingSession(sessionId, fragments, envelope);
// Add local placeholder message. // Add local placeholder message.
final messagesProvider = context.read<MessagesProvider>();
final msgId = 'img_${sessionId}_sent'; final msgId = 'img_${sessionId}_sent';
failedMessageId = msgId;
final isChannel = final isChannel =
_destinationType == _destinationType ==
MessageDestinationPreferences.destinationTypeChannel; MessageDestinationPreferences.destinationTypeChannel;
@@ -1084,6 +1123,7 @@ class _MessagesTabState extends State<MessagesTab> {
channelIdx: channelIdx ?? 0, channelIdx: channelIdx ?? 0,
text: envelopeText, text: envelopeText,
messageId: msgId, messageId: msgId,
floodScopeKey: _channelRegionScopeKey,
); );
} else if (recipient != null) { } else if (recipient != null) {
final sent = await connectionProvider.sendTextMessage( final sent = await connectionProvider.sendTextMessage(
@@ -1111,10 +1151,24 @@ class _MessagesTabState extends State<MessagesTab> {
'chunk=${imageDataBytesPerFragment}B', 'chunk=${imageDataBytesPerFragment}B',
); );
if (isChannel) {
for (final fragment in fragments) {
await connectionProvider.sendChannelData(
channelIdx: channelIdx ?? 0,
dataType: MeshCoreConstants.dataTypeDev,
payload: fragment.encodeBinary(),
floodScopeKey: _channelRegionScopeKey,
);
}
}
// Image fragments are always served on demand after an explicit IR2 // Image fragments are always served on demand after an explicit IR2
// fetch request, including direct contacts. // fetch request, including direct contacts.
} catch (e, st) { } catch (e, st) {
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st'); debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
if (failedMessageId != null) {
messagesProvider.markMessageFailed(failedMessageId);
}
if (!mounted) return; if (!mounted) return;
ToastLogger.error(context, 'Image send failed'); ToastLogger.error(context, 'Image send failed');
} finally { } finally {
@@ -1398,6 +1452,7 @@ class _MessagesTabState extends State<MessagesTab> {
channelIdx: channelIdx ?? 0, channelIdx: channelIdx ?? 0,
text: envelopeText, text: envelopeText,
messageId: msgId, messageId: msgId,
floodScopeKey: _channelRegionScopeKey,
); );
} else if (recipient != null) { } else if (recipient != null) {
final sentSuccessfully = await connectionProvider.sendTextMessage( final sentSuccessfully = await connectionProvider.sendTextMessage(
@@ -1423,6 +1478,16 @@ class _MessagesTabState extends State<MessagesTab> {
} }
debugPrint('🎙️ [Voice] envelope sent for session $sessionId'); debugPrint('🎙️ [Voice] envelope sent for session $sessionId');
if (isChannel) {
for (final packet in encodedPackets) {
await connectionProvider.sendChannelData(
channelIdx: channelIdx ?? 0,
dataType: MeshCoreConstants.dataTypeDev,
payload: packet.encodeBinary(),
floodScopeKey: _channelRegionScopeKey,
);
}
}
// Mark the placeholder message as "sent" (ackTag=0, timeout=0 = no ACK tracking). // Mark the placeholder message as "sent" (ackTag=0, timeout=0 = no ACK tracking).
// addSentMessage() forces deliveryStatus.sending; we upgrade it here so the // addSentMessage() forces deliveryStatus.sending; we upgrade it here so the
// bubble shows "Sent" instead of "Sending" once all packets are on the wire. // bubble shows "Sent" instead of "Sending" once all packets are on the wire.
@@ -1572,205 +1637,121 @@ class _MessagesTabState extends State<MessagesTab> {
void _showComposerActions() { void _showComposerActions() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: Colors.transparent, showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) { builder: (sheetContext) {
final theme = Theme.of(sheetContext); final theme = Theme.of(sheetContext);
final colorScheme = theme.colorScheme;
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
Future<void> runAction(Future<void> Function() action) async { Future<void> runAction(Future<void> Function() action) async {
await _runAfterSheetDismissal(sheetContext, action); await _runAfterSheetDismissal(sheetContext, action);
} }
final actions = <Widget>[
_ComposerActionTile(
icon: Icons.search_rounded,
title: l10n.searchMessages,
color: const Color(0xFF2B6CB0),
onTap: () => runAction(() async {
_showFilteredMessageSearch();
}),
),
_ComposerActionTile(
icon: Icons.add_location_alt_rounded,
title: l10n.sendSarMarker,
color: const Color(0xFFB45309),
onTap: () => runAction(() async {
_showSarDialog();
}),
),
if (_voiceSupported)
_ComposerActionTile(
icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded,
title: _isRecording ? 'Stop recording' : 'Record voice',
color: const Color(0xFF7C3AED),
enabled: !_isSendingVoice,
busy: _isSendingVoice,
onTap: !_isSendingVoice
? () => runAction(() async {
if (_isRecording) {
await _stopAndSendVoice();
} else {
await _startVoiceRecording();
}
})
: null,
),
_ComposerActionTile(
icon: Icons.photo_library_rounded,
title: l10n.sendImageFromGallery,
color: const Color(0xFF0F766E),
enabled: !_isSendingImage,
busy: _isSendingImage,
onTap: !_isSendingImage
? () => runAction(() async {
await _pickAndSendImage(source: ImageSource.gallery);
})
: null,
),
_ComposerActionTile(
icon: Icons.camera_alt_rounded,
title: l10n.takePhoto,
color: const Color(0xFF2563EB),
enabled: !_isSendingImage,
busy: _isSendingImage,
onTap: !_isSendingImage
? () => runAction(() async {
await _pickAndSendImage(source: ImageSource.camera);
})
: null,
),
_ComposerActionTile(
icon: Icons.grid_3x3_rounded,
title: l10n.startTictactoe,
color: const Color(0xFFBE185D),
onTap: () => runAction(() async {
await _startTicTacToeGame();
}),
),
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel)
_ComposerActionTile(
icon: _channelRegionScopeName != null
? Icons.language_rounded
: Icons.public_rounded,
title: _channelRegionScopeName != null
? '${l10n.regionScope}: $_channelRegionScopeName'
: l10n.setRegionScope,
color: const Color(0xFF7C3AED),
onTap: () => runAction(() async {
_showRegionScopeSheet();
}),
),
];
return SafeArea( return SafeArea(
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
maxHeight: MediaQuery.of(sheetContext).size.height * 0.72, maxHeight: MediaQuery.of(sheetContext).size.height * 0.72,
), ),
child: Container( child: SingleChildScrollView(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12), padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
decoration: BoxDecoration( child: Column(
gradient: LinearGradient( mainAxisSize: MainAxisSize.min,
begin: Alignment.topCenter, crossAxisAlignment: CrossAxisAlignment.start,
end: Alignment.bottomCenter, children: [
colors: [ Text(
colorScheme.surface, 'More actions',
colorScheme.surfaceContainerLow, style: theme.textTheme.titleLarge?.copyWith(
], fontWeight: FontWeight.w700,
), ),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(32),
bottom: Radius.circular(28),
),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.14),
blurRadius: 28,
offset: const Offset(0, 10),
), ),
], const SizedBox(height: 16),
), for (var index = 0; index < actions.length; index++) ...[
child: SingleChildScrollView( actions[index],
padding: const EdgeInsets.fromLTRB(16, 12, 16, 18), if (index != actions.length - 1) const SizedBox(height: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 44,
height: 4,
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(999),
),
),
),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'More actions',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -0.4,
),
),
const SizedBox(height: 4),
Text(
'Search, share, or start something from this chat.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 12),
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: colorScheme.outlineVariant.withValues(
alpha: 0.18,
),
),
),
child: IconButton(
onPressed: () => Navigator.pop(sheetContext),
tooltip: l10n.close,
icon: const Icon(Icons.close_rounded),
),
),
],
),
const SizedBox(height: 18),
LayoutBuilder(
builder: (context, constraints) {
final actions = <Widget>[
_ComposerActionTile(
icon: Icons.search_rounded,
title: l10n.searchMessages,
subtitle: 'Find text in the current conversation',
color: const Color(0xFF2B6CB0),
onTap: () => runAction(() async {
_showFilteredMessageSearch();
}),
),
_ComposerActionTile(
icon: Icons.add_location_alt_rounded,
title: l10n.sendSarMarker,
subtitle: 'Share a marker with coordinates',
color: const Color(0xFFB45309),
onTap: () => runAction(() async {
_showSarDialog();
}),
),
if (_voiceSupported)
_ComposerActionTile(
icon: _isRecording
? Icons.stop_rounded
: Icons.mic_rounded,
title: _isRecording
? 'Stop recording'
: 'Record voice',
subtitle: _isSendingVoice
? 'Voice message is sending'
: _isRecording
? 'Finish and send your clip'
: 'Capture and send a voice note',
color: const Color(0xFF7C3AED),
enabled: !_isSendingVoice,
onTap: !_isSendingVoice
? () => runAction(() async {
if (_isRecording) {
await _stopAndSendVoice();
} else {
await _startVoiceRecording();
}
})
: null,
),
_ComposerActionTile(
icon: Icons.photo_library_rounded,
title: l10n.sendImageFromGallery,
subtitle: 'Choose an image from your library',
color: const Color(0xFF0F766E),
enabled: !_isSendingImage,
onTap: !_isSendingImage
? () => runAction(() async {
await _pickAndSendImage(
source: ImageSource.gallery,
);
})
: null,
),
_ComposerActionTile(
icon: Icons.camera_alt_rounded,
title: l10n.takePhoto,
subtitle: 'Capture something right now',
color: const Color(0xFF2563EB),
enabled: !_isSendingImage,
onTap: !_isSendingImage
? () => runAction(() async {
await _pickAndSendImage(
source: ImageSource.camera,
);
})
: null,
),
_ComposerActionTile(
icon: Icons.grid_3x3_rounded,
title: l10n.startTictactoe,
subtitle: l10n.dmOnly,
color: const Color(0xFFBE185D),
onTap: () => runAction(() async {
await _startTicTacToeGame();
}),
),
];
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: actions.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
mainAxisExtent: 126,
),
itemBuilder: (context, index) => actions[index],
);
},
),
], ],
), ],
), ),
), ),
), ),
@@ -1779,6 +1760,48 @@ class _MessagesTabState extends State<MessagesTab> {
); );
} }
void _showRegionScopeSheet() {
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
final l10n = AppLocalizations.of(context)!;
showModalBottomSheet(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) {
return _RegionScopeSheet(
currentScopeName: _channelRegionScopeName,
l10n: l10n,
onScopeSelected: (String? name) async {
Navigator.of(sheetContext).pop();
if (name == null) {
// Clear scope
await RegionScopePreferences.clearScope(channelIdx);
if (!mounted) return;
setState(() {
_channelRegionScopeName = null;
_channelRegionScopeKey = null;
_channelRegionScopes.remove(channelIdx);
});
ToastLogger.success(context, l10n.regionScopeCleared);
} else {
// Set scope
final key = RegionScopePreferences.deriveRegionKey(name);
await RegionScopePreferences.setScope(channelIdx, name, key);
if (!mounted) return;
setState(() {
_channelRegionScopeName = name;
_channelRegionScopeKey = key;
_channelRegionScopes[channelIdx] = name;
});
ToastLogger.success(context, l10n.regionScopeSet(name));
}
},
);
},
);
}
void _showFilteredMessageSearch() { void _showFilteredMessageSearch() {
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
final scopedMessages = _getFilteredMessages(messagesProvider); final scopedMessages = _getFilteredMessages(messagesProvider);
@@ -2065,6 +2088,7 @@ class _MessagesTabState extends State<MessagesTab> {
channelIdx: 0, channelIdx: 0,
text: sarMessage, text: sarMessage,
messageId: messageId, messageId: messageId,
floodScopeKey: _channelRegionScopeKey,
); );
if (!mounted) return; if (!mounted) return;
@@ -2363,6 +2387,10 @@ class _MessagesTabState extends State<MessagesTab> {
onStartVoiceRecording: _startVoiceRecording, onStartVoiceRecording: _startVoiceRecording,
onStopAndSendVoice: _stopAndSendVoice, onStopAndSendVoice: _stopAndSendVoice,
onSendMessage: _sendMessage, onSendMessage: _sendMessage,
regionScopeName: _channelRegionScopeName,
onRegionScopeTap: _channelRegionScopeName != null
? _showRegionScopeSheet
: null,
), ),
), ),
], ],
@@ -2372,3 +2400,165 @@ class _MessagesTabState extends State<MessagesTab> {
); );
} }
} }
/// Bottom sheet for selecting a region scope for the current channel.
class _RegionScopeSheet extends StatefulWidget {
final String? currentScopeName;
final AppLocalizations l10n;
final ValueChanged<String?> onScopeSelected;
const _RegionScopeSheet({
required this.currentScopeName,
required this.l10n,
required this.onScopeSelected,
});
@override
State<_RegionScopeSheet> createState() => _RegionScopeSheetState();
}
class _RegionScopeSheetState extends State<_RegionScopeSheet> {
final TextEditingController _nameController = TextEditingController();
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
void _submitManualName() {
var name = _nameController.text.trim();
if (name.isEmpty) return;
if (!name.startsWith('#')) name = '#$name';
if (utf8.encode(name).length > 30) return;
widget.onScopeSelected(name);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final l10n = widget.l10n;
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.72,
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.regionScope,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
l10n.regionScopeWarning,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
_RegionOptionTile(
label: l10n.regionScopeNone,
isSelected: widget.currentScopeName == null,
onTap: () => widget.onScopeSelected(null),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _nameController,
decoration: InputDecoration(
hintText: l10n.enterRegionName,
isDense: true,
prefixText: '#',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
onSubmitted: (_) => _submitManualName(),
),
),
const SizedBox(width: 8),
FilledButton.tonal(
onPressed: _submitManualName,
child: const Icon(Icons.check_rounded, size: 20),
),
],
),
],
),
),
),
);
}
}
class _RegionOptionTile extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _RegionOptionTile({
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
isSelected
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
size: 20,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface,
),
),
),
],
),
),
),
);
}
}

View File

@@ -105,120 +105,23 @@ class _SensorsTabState extends State<SensorsTab> {
contactsProvider, contactsProvider,
connectionProvider: context.read<ConnectionProvider>(), connectionProvider: context.read<ConnectionProvider>(),
); );
final searchController = TextEditingController(); await showModalBottomSheet<void>(
context: context,
try { showDragHandle: true,
await showModalBottomSheet<void>( isScrollControlled: true,
context: context, builder: (sheetContext) => AddSensorSheet(
showDragHandle: true, candidates: candidates,
isScrollControlled: true, onClose: () => Navigator.of(sheetContext).pop(),
builder: (sheetContext) { onSelect: (contact) async {
if (candidates.isEmpty) { await sensorsProvider.addSensor(contact);
return const SafeArea( if (!sheetContext.mounted) return;
child: Padding( Navigator.of(sheetContext).pop();
padding: EdgeInsets.all(24), ScaffoldMessenger.of(context).showSnackBar(
child: Text( SnackBar(content: Text('${contact.displayName} added to Sensors')),
'No eligible nodes available. Discover a relay or node first.',
),
),
);
}
return StatefulBuilder(
builder: (context, setModalState) {
final query = searchController.text.trim().toLowerCase();
final filteredCandidates = candidates.where((contact) {
if (query.isEmpty) {
return true;
}
return contact.displayName.toLowerCase().contains(query) ||
contact.publicKeyHex.toLowerCase().contains(query);
}).toList();
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 20),
children: [
ListTile(
title: const Text(
'Add sensor node',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
AppLocalizations.of(
context,
)!.pickARelayOrNodeToWatchInSensors,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: TextField(
controller: searchController,
onChanged: (_) => setModalState(() {}),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Search sensors',
border: OutlineInputBorder(),
),
),
),
if (filteredCandidates.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
child: Text(
'No sensor candidates match your search.',
),
),
...filteredCandidates.map(
(contact) => ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
),
leading: CircleAvatar(
radius: 24,
backgroundColor: const Color(0xFFDDEAF8),
child: Icon(
_typeIcon(contact),
color: const Color(0xFF1E4F7A),
),
),
title: Text(contact.displayName),
subtitle: _SensorCandidatePreview(contact: contact),
isThreeLine: true,
onTap: () async {
await sensorsProvider.addSensor(contact);
if (!sheetContext.mounted) return;
Navigator.of(sheetContext).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${contact.displayName} added to Sensors',
),
),
);
},
),
),
],
),
),
);
},
); );
}, },
); ),
} finally { );
searchController.dispose();
}
} }
Future<void> _showMetricSelector( Future<void> _showMetricSelector(
@@ -228,7 +131,7 @@ class _SensorsTabState extends State<SensorsTab> {
) async { ) async {
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (pageContext) => _SensorCustomizeView( builder: (pageContext) => SensorCustomizeView(
publicKeyHex: publicKeyHex, publicKeyHex: publicKeyHex,
initialContact: contact, initialContact: contact,
onRenameMetric: onRenameMetric:
@@ -474,9 +377,153 @@ class _SensorsTabState extends State<SensorsTab> {
} }
} }
class _SensorCustomizeView extends StatelessWidget { class AddSensorSheet extends StatefulWidget {
final List<Contact> candidates;
final Future<void> Function(Contact contact) onSelect;
final VoidCallback onClose;
const AddSensorSheet({
super.key,
required this.candidates,
required this.onSelect,
required this.onClose,
});
@override
State<AddSensorSheet> createState() => _AddSensorSheetState();
}
class _AddSensorSheetState extends State<AddSensorSheet> {
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.candidates.isEmpty) {
return const SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'No eligible nodes available. Discover a relay or node first.',
),
),
);
}
final l10n = AppLocalizations.of(context)!;
final query = _searchController.text.trim().toLowerCase();
final filteredCandidates = widget.candidates.where((contact) {
if (query.isEmpty) {
return true;
}
return contact.displayName.toLowerCase().contains(query) ||
contact.publicKeyHex.toLowerCase().contains(query);
}).toList();
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 12, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Add sensor node',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(l10n.pickARelayOrNodeToWatchInSensors),
],
),
),
IconButton(
onPressed: widget.onClose,
tooltip: l10n.close,
icon: const Icon(Icons.close),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: TextField(
controller: _searchController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Search sensors',
border: OutlineInputBorder(),
),
),
),
Expanded(
child: filteredCandidates.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
child: Align(
alignment: Alignment.topLeft,
child: Text(
'No sensor candidates match your search.',
),
),
)
: ListView.builder(
padding: const EdgeInsets.only(bottom: 20),
itemCount: filteredCandidates.length,
itemBuilder: (context, index) {
final contact = filteredCandidates[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
),
leading: CircleAvatar(
radius: 24,
backgroundColor: const Color(0xFFDDEAF8),
child: Icon(
_typeIcon(contact),
color: const Color(0xFF1E4F7A),
),
),
title: Text(contact.displayName),
subtitle: _SensorCandidatePreview(contact: contact),
isThreeLine: true,
onTap: () => widget.onSelect(contact),
);
},
),
),
],
),
),
),
);
}
}
class SensorCustomizeView extends StatelessWidget {
final String publicKeyHex; final String publicKeyHex;
final Contact? initialContact; final Contact? initialContact;
final bool showLivePreview;
final Future<void> Function({ final Future<void> Function({
required BuildContext context, required BuildContext context,
required String publicKeyHex, required String publicKeyHex,
@@ -485,10 +532,12 @@ class _SensorCustomizeView extends StatelessWidget {
}) })
onRenameMetric; onRenameMetric;
const _SensorCustomizeView({ const SensorCustomizeView({
super.key,
required this.publicKeyHex, required this.publicKeyHex,
required this.initialContact, required this.initialContact,
required this.onRenameMetric, required this.onRenameMetric,
this.showLivePreview = true,
}); });
@override @override
@@ -533,35 +582,39 @@ class _SensorCustomizeView extends StatelessWidget {
body: ListView( body: ListView(
padding: EdgeInsets.fromLTRB(16, 16, 16, 24), padding: EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [ children: [
_SensorCustomizeSectionCard( if (showLivePreview)
title: AppLocalizations.of(context)!.livePreview, _SensorCustomizeSectionCard(
subtitle: title: AppLocalizations.of(context)!.livePreview,
'Changes apply immediately. This card matches the current dashboard layout for this sensor.', subtitle:
child: SensorTelemetryCard( 'Changes apply immediately. This card matches the current dashboard layout for this sensor.',
contact: contact, child: SensorTelemetryCard(
state: sensorsProvider.stateFor(publicKeyHex), contact: contact,
visibleFields: visibleFields, state: sensorsProvider.stateFor(publicKeyHex),
fieldOrder: sensorsProvider.metricOrderFor( visibleFields: visibleFields,
publicKeyHex, fieldOrder: sensorsProvider.metricOrderFor(
visibleFields, publicKeyHex,
), visibleFields,
labelOverrides: sensorsProvider.labelOverridesFor( ),
publicKeyHex, labelOverrides: sensorsProvider.labelOverridesFor(
), publicKeyHex,
onShowMetHistory: contact == null ),
? null onShowMetHistory: contact == null
: (contact) => showBTHomeMetHistorySheet( ? null
context, : (contact) => showBTHomeMetHistorySheet(
contact: contact, context,
contact: contact,
),
fieldSpans: {
for (final field in visibleFields)
field: sensorsProvider.fieldSpanFor(
publicKeyHex,
field,
), ),
fieldSpans: { },
for (final field in visibleFields) margin: EdgeInsets.zero,
field: sensorsProvider.fieldSpanFor(publicKeyHex, field), emptyMetricsMessage: 'No telemetry fields available yet.',
}, ),
margin: EdgeInsets.zero,
emptyMetricsMessage: 'No telemetry fields available yet.',
), ),
),
_SensorCustomizeSectionCard( _SensorCustomizeSectionCard(
title: AppLocalizations.of(context)!.refreshSchedule, title: AppLocalizations.of(context)!.refreshSchedule,
subtitle: subtitle:

View File

@@ -16,6 +16,7 @@ import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/drawing_provider.dart'; import '../providers/drawing_provider.dart';
import '../providers/map_provider.dart'; import '../providers/map_provider.dart';
import '../models/contact.dart';
import '../models/config_profile.dart'; import '../models/config_profile.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart'; import '../services/locale_preferences.dart';
@@ -79,6 +80,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _fastLocationUpdatesEnabled = false; bool _fastLocationUpdatesEnabled = false;
double _fastLocationMovementThresholdMeters = 10.0; double _fastLocationMovementThresholdMeters = 10.0;
int _fastLocationActiveCadenceSeconds = 10; int _fastLocationActiveCadenceSeconds = 10;
int? _fastLocationChannelIdx;
bool _rotateMapWithHeading = false; bool _rotateMapWithHeading = false;
bool _showMapDebugInfo = false; bool _showMapDebugInfo = false;
bool _openMapInFullscreen = false; bool _openMapInFullscreen = false;
@@ -314,6 +316,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_locationService.fastLocationMovementThresholdMeters; _locationService.fastLocationMovementThresholdMeters;
_fastLocationActiveCadenceSeconds = _fastLocationActiveCadenceSeconds =
_locationService.fastLocationActiveCadenceSeconds; _locationService.fastLocationActiveCadenceSeconds;
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
}); });
} }
@@ -338,7 +341,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Meters', labelText: 'Meters',
helperText: 'Valid range: 1 to 1000 meters', helperText: 'Valid range: 10 to 1000 meters',
), ),
), ),
actions: [ actions: [
@@ -350,7 +353,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () { onPressed: () {
final parsed = double.tryParse(controller.text.trim()); final parsed = double.tryParse(controller.text.trim());
if (parsed == null) return; if (parsed == null) return;
Navigator.pop(context, parsed.clamp(1.0, 1000.0)); Navigator.pop(context, parsed.clamp(10.0, 1000.0));
}, },
child: const Text('Save'), child: const Text('Save'),
), ),
@@ -379,7 +382,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Seconds', labelText: 'Seconds',
helperText: 'Valid range: 5 to 60 seconds', helperText: 'Valid range: 10 to 31 seconds',
), ),
), ),
actions: [ actions: [
@@ -391,7 +394,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () { onPressed: () {
final parsed = int.tryParse(controller.text.trim()); final parsed = int.tryParse(controller.text.trim());
if (parsed == null) return; if (parsed == null) return;
Navigator.pop(context, parsed.clamp(5, 60)); Navigator.pop(context, parsed.clamp(10, 31));
}, },
child: const Text('Save'), child: const Text('Save'),
), ),
@@ -407,6 +410,97 @@ class _SettingsScreenState extends State<SettingsScreen> {
}); });
} }
String _describeFastLocationChannel(List<Contact> channels) {
final channelIdx = _fastLocationChannelIdx;
if (channelIdx == null) {
return 'Not set';
}
for (final channel in channels) {
final idx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
if (idx == channelIdx) {
return '${channel.getLocalizedDisplayName(context)} (slot $channelIdx)';
}
}
return 'Channel $channelIdx unavailable';
}
Future<void> _sendTestFastLocationUpdate() async {
final appProvider = context.read<AppProvider>();
final sent = await appProvider.sendTestFastLocationUpdate();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
sent
? 'Test fast GPS update sent.'
: 'Unable to send test fast GPS update.',
),
backgroundColor: sent ? Colors.green : Colors.orange,
),
);
}
Future<void> _editFastLocationChannel() async {
final channels =
List<Contact>.from(context.read<ContactsProvider>().channels)
..removeWhere((c) => c.isPublicChannel)
..sort((a, b) {
final aIdx = a.publicKey.length > 1 ? a.publicKey[1] : 0;
final bIdx = b.publicKey.length > 1 ? b.publicKey[1] : 0;
return aIdx.compareTo(bIdx);
});
final selected = await showModalBottomSheet<int?>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.block),
title: const Text('Disable fast GPS publishing'),
trailing: _fastLocationChannelIdx == null
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(sheetContext, -1),
),
for (final channel in channels)
ListTile(
leading: const Icon(Icons.tag),
title: Text(channel.getLocalizedDisplayName(sheetContext)),
subtitle: Text(
'Channel ${channel.publicKey.length > 1 ? channel.publicKey[1] : 0}',
),
trailing:
_fastLocationChannelIdx ==
(channel.publicKey.length > 1
? channel.publicKey[1]
: 0)
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(
sheetContext,
channel.publicKey.length > 1 ? channel.publicKey[1] : 0,
),
),
],
),
),
);
if (selected == null) return;
await _locationService.updateFastLocationChannelIdx(
selected < 0 ? null : selected,
);
if (!mounted) return;
setState(() {
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
});
}
Future<void> _saveImageMaxSize(int size) async { Future<void> _saveImageMaxSize(int size) async {
await ImagePreferences.setMaxSize(size); await ImagePreferences.setMaxSize(size);
if (!mounted) return; if (!mounted) return;
@@ -1766,6 +1860,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationActiveCadence, onTap: _editFastLocationActiveCadence,
), ),
ListTile(
leading: const Icon(Icons.forum),
title: const Text('Fast GPS target channel'),
subtitle: Text(
_describeFastLocationChannel(
context.watch<ContactsProvider>().channels,
),
),
trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationChannel,
),
ListTile(
leading: const Icon(Icons.send),
title: const Text('Test send update'),
subtitle: const Text(
'Send one fast GPS update immediately to the configured channel.',
),
trailing: const Icon(Icons.chevron_right),
onTap: _sendTestFastLocationUpdate,
),
ListTile( ListTile(
leading: Icon(Icons.location_on), leading: Icon(Icons.location_on),
title: Text(AppLocalizations.of(context)!.locationPermission), title: Text(AppLocalizations.of(context)!.locationPermission),

View File

@@ -1,602 +0,0 @@
import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:provider/provider.dart';
import '../models/device_info.dart';
import '../providers/connection_provider.dart';
import '../widgets/device/spectrum_scan_panel.dart';
import '../l10n/app_localizations.dart';
class SpectrumScanScreen extends StatefulWidget {
const SpectrumScanScreen({super.key});
@override
State<SpectrumScanScreen> createState() => _SpectrumScanScreenState();
}
class _SpectrumScanScreenState extends State<SpectrumScanScreen> {
static const List<String> _bandwidthOptions = [
'7.8 kHz',
'10.4 kHz',
'15.6 kHz',
'20.8 kHz',
'31.25 kHz',
'41.7 kHz',
'62.5 kHz',
'125 kHz',
'250 kHz',
'500 kHz',
];
String _selectedBandwidth = '62.5 kHz';
bool _isSpectrumScanRunning = false;
bool _rangeInitialized = false;
String? _lastRangeSourceKey;
late double _scanRangeMinMhz;
late double _scanRangeMaxMhz;
late RangeValues _scanRangeValues;
List<SpectrumScanCandidate> _scanCandidates = const [];
int? _selectedScanFrequencyKhz;
int _completedScanSectors = 0;
int _totalScanSectors = 0;
List<SpectrumScanCandidate> get _recommendedScanCandidates =>
_scanCandidates.take(8).toList();
@override
void initState() {
super.initState();
final deviceInfo = _deviceInfo;
if (deviceInfo.radioBw != null &&
deviceInfo.radioBw! >= 0 &&
deviceInfo.radioBw! < _bandwidthOptions.length) {
_selectedBandwidth = _bandwidthOptions[deviceInfo.radioBw!];
}
_syncRangeFromDevice(deviceInfo);
_selectedScanFrequencyKhz = deviceInfo.radioFreq;
}
DeviceInfo get _deviceInfo => context.read<ConnectionProvider>().deviceInfo;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_syncRangeFromDevice(context.watch<ConnectionProvider>().deviceInfo);
}
void _syncRangeFromDevice(DeviceInfo deviceInfo) {
final minKhz = deviceInfo.spectrumScanMinKhz;
final maxKhz = deviceInfo.spectrumScanMaxKhz;
late double normalizedMinMhz;
late double normalizedMaxMhz;
late String sourceKey;
if (minKhz != null && maxKhz != null && maxKhz > minKhz) {
final normalized = _normalizeScanRange(
minKhz / 1000.0,
maxKhz / 1000.0,
deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null,
);
normalizedMinMhz = normalized.$1;
normalizedMaxMhz = normalized.$2;
sourceKey = 'fw:$minKhz:$maxKhz';
} else {
final normalized = _normalizeScanRange(
null,
null,
deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null,
);
normalizedMinMhz = normalized.$1;
normalizedMaxMhz = normalized.$2;
sourceKey =
'fallback:${deviceInfo.radioFreq != null ? deviceInfo.radioFreq! ~/ 1000 : 869525}';
}
if (_rangeInitialized && _lastRangeSourceKey == sourceKey) {
return;
}
_scanRangeMinMhz = normalizedMinMhz;
_scanRangeMaxMhz = normalizedMaxMhz;
if (_scanRangeMaxMhz <= _scanRangeMinMhz) {
_scanRangeMaxMhz = _scanRangeMinMhz + 0.5;
}
_scanRangeValues = RangeValues(_scanRangeMinMhz, _scanRangeMaxMhz);
_rangeInitialized = true;
_lastRangeSourceKey = sourceKey;
}
(double, double) _normalizeScanRange(
double? minMhz,
double? maxMhz,
double? centerMhz,
) {
const hardMinMhz = 800.0;
const hardMaxMhz = 950.0;
final center = centerMhz ?? 869.525;
if (minMhz != null && maxMhz != null) {
final clampedMin = minMhz.clamp(hardMinMhz, hardMaxMhz);
final clampedMax = maxMhz.clamp(hardMinMhz, hardMaxMhz);
if (clampedMax > clampedMin) {
return (clampedMin, clampedMax);
}
}
if (center >= 900.0 && center <= 930.0) {
return (902.0, 928.0);
}
return (863.0, 870.0);
}
double _bandwidthToKhz(String bw) {
switch (bw) {
case '7.8 kHz':
return 7.8;
case '10.4 kHz':
return 10.4;
case '15.6 kHz':
return 15.6;
case '20.8 kHz':
return 20.8;
case '31.25 kHz':
return 31.25;
case '41.7 kHz':
return 41.7;
case '62.5 kHz':
return 62.5;
case '125 kHz':
return 125.0;
case '250 kHz':
return 250.0;
case '500 kHz':
return 500.0;
default:
return 62.5;
}
}
String _currentParamProfile(DeviceInfo deviceInfo) {
final sf = deviceInfo.radioSf ?? 8;
final cr = deviceInfo.radioCr ?? 8;
return 'BW $_selectedBandwidth | SF$sf | CR 4/$cr';
}
String _recommendationTitle(int index) {
switch (index) {
case 0:
return 'Best candidate';
case 1:
return 'Alternate';
case 2:
return 'Fallback';
default:
return 'Candidate ${index + 1}';
}
}
List<int> _possibleBracketFrequenciesKhz() {
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
final halfBandwidthKhz = bandwidthKhz / 2.0;
final startKhz = (_scanRangeValues.start * 1000).round();
final stopKhz = (_scanRangeValues.end * 1000).round();
final firstCenterKhz = (startKhz + halfBandwidthKhz).round();
final lastCenterKhz = (stopKhz - halfBandwidthKhz).round();
if (lastCenterKhz < firstCenterKhz) {
return const [];
}
final stepKhz = bandwidthKhz >= 125.0 ? bandwidthKhz.round() : 25;
final centers = <int>[];
for (
var centerKhz = firstCenterKhz;
centerKhz <= lastCenterKhz && centers.length < 8;
centerKhz += stepKhz
) {
centers.add(centerKhz);
}
if (centers.isEmpty || centers.last != lastCenterKhz) {
centers.add(lastCenterKhz);
}
return centers.toSet().toList()..sort();
}
void _resetDerivedScanResults() {
_scanCandidates = const [];
_selectedScanFrequencyKhz = null;
}
List<(int startKhz, int stopKhz)> _buildScanSectors(double bandwidthKhz) {
final startKhz = (_scanRangeValues.start * 1000).round();
final stopKhz = (_scanRangeValues.end * 1000).round();
final sectorWidthKhz = (bandwidthKhz * 24).round().clamp(250, 1200);
final overlapKhz = bandwidthKhz.round().clamp(8, 500);
final sectors = <(int startKhz, int stopKhz)>[];
var sectorStartKhz = startKhz;
while (sectorStartKhz < stopKhz) {
final sectorStopKhz = (sectorStartKhz + sectorWidthKhz).clamp(
sectorStartKhz + overlapKhz,
stopKhz,
);
sectors.add((sectorStartKhz, sectorStopKhz));
if (sectorStopKhz >= stopKhz) {
break;
}
sectorStartKhz = sectorStopKhz - overlapKhz;
}
return sectors;
}
List<SpectrumScanCandidate> _mergeSectorCandidates(
Iterable<SpectrumScanCandidate> candidates,
) {
final byFrequency = <int, SpectrumScanCandidate>{};
for (final candidate in candidates) {
final existing = byFrequency[candidate.centerFrequencyKhz];
if (existing == null ||
candidate.occupancyPercent < existing.occupancyPercent ||
(candidate.occupancyPercent == existing.occupancyPercent &&
candidate.peakRssiDbm < existing.peakRssiDbm) ||
(candidate.occupancyPercent == existing.occupancyPercent &&
candidate.peakRssiDbm == existing.peakRssiDbm &&
candidate.avgRssiDbm < existing.avgRssiDbm)) {
byFrequency[candidate.centerFrequencyKhz] = candidate;
}
}
final merged = byFrequency.values.toList()
..sort((a, b) {
final occupancyCompare = a.occupancyPercent.compareTo(
b.occupancyPercent,
);
if (occupancyCompare != 0) return occupancyCompare;
final peakCompare = a.peakRssiDbm.compareTo(b.peakRssiDbm);
if (peakCompare != 0) return peakCompare;
return a.avgRssiDbm.compareTo(b.avgRssiDbm);
});
return merged;
}
Future<void> _runSpectrumScan() async {
final connectionProvider = context.read<ConnectionProvider>();
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
final sectors = _buildScanSectors(bandwidthKhz);
final sectorCandidates = <SpectrumScanCandidate>[];
setState(() {
_isSpectrumScanRunning = true;
_resetDerivedScanResults();
_completedScanSectors = 0;
_totalScanSectors = sectors.length;
});
try {
for (var i = 0; i < sectors.length; i++) {
final sector = sectors[i];
final result = await connectionProvider.scanSpectrum(
startFrequencyKhz: sector.$1,
stopFrequencyKhz: sector.$2,
bandwidthKhz: bandwidthKhz.round(),
stepKhz: (bandwidthKhz / 2).round().clamp(1, 1000),
dwellMs: 160,
thresholdDb: 8,
);
if (result != null) {
sectorCandidates.addAll(result.candidates);
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
if (mounted) {
setState(() {
_scanCandidates = mergedCandidates;
if (_selectedScanFrequencyKhz == null &&
mergedCandidates.isNotEmpty) {
_selectedScanFrequencyKhz =
mergedCandidates.first.centerFrequencyKhz;
}
_completedScanSectors = i + 1;
});
}
} else if (mounted) {
setState(() {
_completedScanSectors = i + 1;
});
}
}
} finally {
if (mounted) {
setState(() {
_isSpectrumScanRunning = false;
if (_completedScanSectors == 0) {
_totalScanSectors = sectors.length;
}
});
}
}
if (!mounted) return;
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
if (mergedCandidates.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.spectrumScanReturnedNoCandidateFrequencies),
backgroundColor: Colors.red,
),
);
return;
}
setState(() {
_scanCandidates = mergedCandidates;
_selectedScanFrequencyKhz = mergedCandidates.first.centerFrequencyKhz;
});
}
Future<void> _applySelectedScanFrequency() async {
if (_selectedScanFrequencyKhz == null) return;
final connectionProvider = context.read<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
await connectionProvider.setRadioParams(
frequency: _selectedScanFrequencyKhz!,
bandwidth: _bandwidthOptions.indexOf(_selectedBandwidth),
spreadingFactor: deviceInfo.radioSf ?? 8,
codingRate: deviceInfo.radioCr ?? 8,
repeat: deviceInfo.clientRepeat,
);
await connectionProvider.refreshDeviceInfo();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Applied ${(_selectedScanFrequencyKhz! / 1000.0).toStringAsFixed(3)} MHz',
),
backgroundColor: Colors.green,
),
);
}
int? _currentPreviewFrequencyKhz(DeviceInfo deviceInfo) {
if (_selectedScanFrequencyKhz != null) {
return _selectedScanFrequencyKhz;
}
return deviceInfo.radioFreq;
}
@override
Widget build(BuildContext context) {
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
final theme = Theme.of(context);
final possibleBracketFrequencies = _possibleBracketFrequenciesKhz();
final recommendedScanCandidates = _recommendedScanCandidates;
final recommendationFrequencies = recommendedScanCandidates.isNotEmpty
? recommendedScanCandidates
.map((candidate) => candidate.centerFrequencyKhz)
.toList()
: possibleBracketFrequencies;
return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.spectrumScan)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
initialValue: _selectedBandwidth,
decoration: const InputDecoration(
labelText: 'Bandwidth',
border: OutlineInputBorder(),
helperText:
'Scan and apply frequencies for this bandwidth',
),
items: _bandwidthOptions.map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (value) {
if (value == null) return;
setState(() {
_selectedBandwidth = value;
_resetDerivedScanResults();
});
},
),
const SizedBox(height: 16),
Text(
deviceInfo.spectrumScanMinKhz != null &&
deviceInfo.spectrumScanMaxKhz != null
? 'Firmware scan range: ${_scanRangeMinMhz.toStringAsFixed(3)}-${_scanRangeMaxMhz.toStringAsFixed(3)} MHz'
: 'Fallback scan range selected from current band. MeshCore commonly uses EU868 or US915.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (_isSpectrumScanRunning) ...[
const SizedBox(height: 8),
Text(
'Scanning sector ${_completedScanSectors + 1} of $_totalScanSectors. Results update as each sector completes.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: 12),
SpectrumScanPanel(
theme: theme,
scanSupported: deviceInfo.supportsSpectrumScan == true,
isRunning: _isSpectrumScanRunning,
rangeMinMhz: _scanRangeMinMhz,
rangeMaxMhz: _scanRangeMaxMhz,
rangeValues: _scanRangeValues,
bandwidthKhz: _bandwidthToKhz(_selectedBandwidth),
selectedFrequencyKhz: _currentPreviewFrequencyKhz(
deviceInfo,
),
graphCandidates: _scanCandidates,
selectableCandidates: recommendedScanCandidates,
onRangeChanged: (values) {
setState(() {
_scanRangeValues = values;
_resetDerivedScanResults();
});
},
onCandidateChanged: (value) {
setState(() {
_selectedScanFrequencyKhz = value;
});
},
onRunScan: _runSpectrumScan,
onApplySelected: _applySelectedScanFrequency,
),
if (recommendationFrequencies.isNotEmpty) ...[
const SizedBox(height: 18),
Text(
_scanCandidates.isNotEmpty
? 'Recommended profiles'
: 'Possible brackets',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
_scanCandidates.isNotEmpty
? 'Suggested frequencies from the latest scan with the radio parameters to keep alongside them.'
: 'Usable frequency brackets derived from the selected span and bandwidth, even without live scan data.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
for (
var i = 0;
i < recommendationFrequencies.length;
i++
) ...[
_RecommendationTile(
title: _recommendationTitle(i),
frequencyKhz: recommendationFrequencies[i],
candidate: recommendedScanCandidates.isNotEmpty
? recommendedScanCandidates[i]
: null,
bandwidthKhz: _bandwidthToKhz(_selectedBandwidth),
paramsLabel: _currentParamProfile(deviceInfo),
isSelected:
_selectedScanFrequencyKhz ==
recommendationFrequencies[i],
onSelect: () {
setState(() {
_selectedScanFrequencyKhz =
recommendationFrequencies[i];
});
},
),
if (i != recommendationFrequencies.length - 1)
const SizedBox(height: 10),
],
],
],
),
),
),
],
),
);
}
}
class _RecommendationTile extends StatelessWidget {
final String title;
final int frequencyKhz;
final SpectrumScanCandidate? candidate;
final double bandwidthKhz;
final String paramsLabel;
final bool isSelected;
final VoidCallback onSelect;
const _RecommendationTile({
required this.title,
required this.frequencyKhz,
required this.candidate,
required this.bandwidthKhz,
required this.paramsLabel,
required this.isSelected,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return InkWell(
onTap: onSelect,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isSelected
? scheme.primaryContainer.withValues(alpha: 0.55)
: scheme.surfaceContainerHighest.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected ? scheme.primary : scheme.outlineVariant,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (isSelected)
Icon(Icons.check_circle, color: scheme.primary, size: 18),
],
),
const SizedBox(height: 8),
Text(
'${(frequencyKhz / 1000.0).toStringAsFixed(3)} MHz',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(paramsLabel, style: theme.textTheme.bodyMedium),
const SizedBox(height: 6),
Text(
candidate != null
? 'Occupancy ${candidate!.occupancyPercent}% | Avg ${candidate!.avgRssiDbm} dBm | Peak ${candidate!.peakRssiDbm} dBm'
: 'Bracket ${(frequencyKhz / 1000.0 - bandwidthKhz / 2000.0).toStringAsFixed(3)}-${(frequencyKhz / 1000.0 + bandwidthKhz / 2000.0).toStringAsFixed(3)} MHz',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
);
}
}

View File

@@ -221,14 +221,13 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
} }
Future<void> _openConnectionDialog() async { Future<void> _openConnectionDialog() async {
await showModalBottomSheet( final connected = await showConnectionDialogFlow(
context: context, context,
isScrollControlled: true, offerPostConnectRepeaterDiscovery: true,
builder: (context) => const ConnectionDialog(),
); );
if (!mounted) return; if (!mounted) return;
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
if (connectionProvider.deviceInfo.isConnected) { if (connected) {
await connectionProvider.refreshDeviceInfo(); await connectionProvider.refreshDeviceInfo();
} }
if (!mounted) return; if (!mounted) return;

View File

@@ -144,11 +144,11 @@ class AppConfigSnapshotService {
} }
if (section.fastLocationMovementThresholdMeters != null) { if (section.fastLocationMovementThresholdMeters != null) {
locationTracking.fastLocationMovementThresholdMeters = locationTracking.fastLocationMovementThresholdMeters =
section.fastLocationMovementThresholdMeters!; section.fastLocationMovementThresholdMeters!.clamp(10.0, 1000.0);
} }
if (section.fastLocationActiveCadenceSeconds != null) { if (section.fastLocationActiveCadenceSeconds != null) {
locationTracking.fastLocationActiveCadenceSeconds = locationTracking.fastLocationActiveCadenceSeconds =
section.fastLocationActiveCadenceSeconds!; section.fastLocationActiveCadenceSeconds!.clamp(10, 31);
} }
await locationTracking.saveSettings(); await locationTracking.saveSettings();
await appProvider.reloadProfileScopedSettings(); await appProvider.reloadProfileScopedSettings();

View File

@@ -0,0 +1,11 @@
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
class CompassSupport {
const CompassSupport._();
static bool get isAvailable =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
}

View File

@@ -20,6 +20,14 @@ import 'profiles_feature_service.dart';
/// - MeshCore mesh network integration /// - MeshCore mesh network integration
/// - Real-time position updates via callbacks /// - Real-time position updates via callbacks
class LocationTrackingService { class LocationTrackingService {
static const double _defaultFastLocationMovementThresholdMeters = 10.0;
static const double _minFastLocationMovementThresholdMeters = 10.0;
static const int _defaultFastLocationActiveCadenceSeconds = 10;
static const int _minFastLocationActiveCadenceSeconds = 10;
static const int _maxFastLocationActiveCadenceSeconds = 31;
static const Duration _fastLocationMinimumUpdateInterval = Duration(
seconds: 31,
);
// ============================================================================ // ============================================================================
// Singleton Pattern // Singleton Pattern
// ============================================================================ // ============================================================================
@@ -49,6 +57,7 @@ class LocationTrackingService {
'fast_location_movement_threshold_meters'; 'fast_location_movement_threshold_meters';
static const String _prefKeyFastActiveCadence = static const String _prefKeyFastActiveCadence =
'fast_location_active_cadence_seconds'; 'fast_location_active_cadence_seconds';
static const String _prefKeyFastChannelIdx = 'fast_location_channel_idx';
String _scopedKey(String baseKey) { String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey); return ProfileStorageScope.scopedKey(baseKey);
@@ -74,10 +83,15 @@ class LocationTrackingService {
bool fastLocationUpdatesEnabled = false; bool fastLocationUpdatesEnabled = false;
/// Distance threshold for fast GPS updates /// Distance threshold for fast GPS updates
double fastLocationMovementThresholdMeters = 10.0; double fastLocationMovementThresholdMeters =
_defaultFastLocationMovementThresholdMeters;
/// Cadence for active-use fast GPS updates /// Cadence for active-use fast GPS updates
int fastLocationActiveCadenceSeconds = 10; int fastLocationActiveCadenceSeconds =
_defaultFastLocationActiveCadenceSeconds;
/// Target channel index for fast GPS updates; null means disabled/unset.
int? fastLocationChannelIdx;
// ============================================================================ // ============================================================================
// State Properties // State Properties
@@ -518,18 +532,30 @@ class LocationTrackingService {
} }
Future<void> updateFastLocationMovementThreshold(double meters) async { Future<void> updateFastLocationMovementThreshold(double meters) async {
fastLocationMovementThresholdMeters = meters.clamp(1.0, 1000.0); fastLocationMovementThresholdMeters = meters.clamp(
_minFastLocationMovementThresholdMeters,
1000.0,
);
await saveSettings(); await saveSettings();
} }
Future<void> updateFastLocationActiveCadenceSeconds(int seconds) async { Future<void> updateFastLocationActiveCadenceSeconds(int seconds) async {
fastLocationActiveCadenceSeconds = seconds.clamp(5, 60); fastLocationActiveCadenceSeconds = seconds.clamp(
_minFastLocationActiveCadenceSeconds,
_maxFastLocationActiveCadenceSeconds,
);
await saveSettings();
_refreshFastLocationTimer();
}
Future<void> updateFastLocationChannelIdx(int? channelIdx) async {
fastLocationChannelIdx = channelIdx;
await saveSettings(); await saveSettings();
_refreshFastLocationTimer(); _refreshFastLocationTimer();
} }
void _evaluateFastLocationMovement(Position position) { void _evaluateFastLocationMovement(Position position) {
if (!fastLocationUpdatesEnabled) return; if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
final previous = _lastFastLocationSentPosition; final previous = _lastFastLocationSentPosition;
if (previous == null) { if (previous == null) {
_emitFastLocationUpdate(position, reason: 'initial'); _emitFastLocationUpdate(position, reason: 'initial');
@@ -552,6 +578,7 @@ class LocationTrackingService {
_fastLocationTimer = null; _fastLocationTimer = null;
if (!isTracking || if (!isTracking ||
!fastLocationUpdatesEnabled || !fastLocationUpdatesEnabled ||
fastLocationChannelIdx == null ||
!_isFastLocationActiveUse) { !_isFastLocationActiveUse) {
return; return;
} }
@@ -567,11 +594,16 @@ class LocationTrackingService {
} }
void _emitFastLocationUpdate(Position position, {required String reason}) { void _emitFastLocationUpdate(Position position, {required String reason}) {
if (!fastLocationUpdatesEnabled) return; if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
final now = DateTime.now(); final now = DateTime.now();
final previous = _lastFastLocationSentPosition; final previous = _lastFastLocationSentPosition;
final previousTime = _lastFastLocationSentAt; final previousTime = _lastFastLocationSentAt;
if (previousTime != null &&
now.difference(previousTime) < _fastLocationMinimumUpdateInterval) {
return;
}
if (previous != null && previousTime != null) { if (previous != null && previousTime != null) {
final distance = Geolocator.distanceBetween( final distance = Geolocator.distanceBetween(
previous.latitude, previous.latitude,
@@ -662,13 +694,16 @@ class LocationTrackingService {
prefs.getBool(_scopedKey(_prefKeyFastLocationEnabled)) ?? false; prefs.getBool(_scopedKey(_prefKeyFastLocationEnabled)) ?? false;
fastLocationMovementThresholdMeters = fastLocationMovementThresholdMeters =
(prefs.getDouble(_scopedKey(_prefKeyFastMovementThreshold)) ?? (prefs.getDouble(_scopedKey(_prefKeyFastMovementThreshold)) ??
gpsUpdateDistance) _defaultFastLocationMovementThresholdMeters)
.clamp(1.0, 1000.0); .clamp(_minFastLocationMovementThresholdMeters, 1000.0);
fastLocationActiveCadenceSeconds = fastLocationActiveCadenceSeconds =
(prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ?? 10).clamp( (prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ??
5, _defaultFastLocationActiveCadenceSeconds)
60, .clamp(
_minFastLocationActiveCadenceSeconds,
_maxFastLocationActiveCadenceSeconds,
); );
fastLocationChannelIdx = prefs.getInt(_scopedKey(_prefKeyFastChannelIdx));
debugPrint('✅ [LocationTracking] Settings loaded'); debugPrint('✅ [LocationTracking] Settings loaded');
debugPrint(' Min distance: ${minDistanceMeters}m'); debugPrint(' Min distance: ${minDistanceMeters}m');
@@ -680,6 +715,7 @@ class LocationTrackingService {
' Fast movement threshold: ${fastLocationMovementThresholdMeters}m', ' Fast movement threshold: ${fastLocationMovementThresholdMeters}m',
); );
debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s'); debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s');
debugPrint(' Fast channel idx: ${fastLocationChannelIdx ?? "unset"}');
} }
/// Save settings to SharedPreferences /// Save settings to SharedPreferences
@@ -709,6 +745,12 @@ class LocationTrackingService {
_scopedKey(_prefKeyFastActiveCadence), _scopedKey(_prefKeyFastActiveCadence),
fastLocationActiveCadenceSeconds, fastLocationActiveCadenceSeconds,
); );
final channelKey = _scopedKey(_prefKeyFastChannelIdx);
if (fastLocationChannelIdx == null) {
await prefs.remove(channelKey);
} else {
await prefs.setInt(channelKey, fastLocationChannelIdx!);
}
debugPrint('✅ [LocationTracking] Settings saved'); debugPrint('✅ [LocationTracking] Settings saved');
} }

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -9,7 +10,7 @@ import '../models/path_selection.dart';
import '../utils/log_rx_route_decoder.dart'; import '../utils/log_rx_route_decoder.dart';
class PathHistoryService { class PathHistoryService {
static const String _storageKey = 'contact_path_history_v1'; static const String _storageKey = 'contact_path_history_v2';
static const int _maxDirectPaths = 20; static const int _maxDirectPaths = 20;
static const int _topRotationCount = 3; static const int _topRotationCount = 3;
@@ -59,6 +60,11 @@ class PathHistoryService {
failureCount: existing?.failureCount ?? 0, failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(), lastUsedAt: DateTime.now(),
lastSucceededAt: existing?.lastSucceededAt,
senderLatitude: existing?.senderLatitude,
senderLongitude: existing?.senderLongitude,
recipientLatitude: existing?.recipientLatitude,
recipientLongitude: existing?.recipientLongitude,
); );
await _saveHistory( await _saveHistory(
@@ -104,6 +110,11 @@ class PathHistoryService {
failureCount: existing?.failureCount ?? 0, failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(), lastUsedAt: DateTime.now(),
lastSucceededAt: existing?.lastSucceededAt,
senderLatitude: existing?.senderLatitude,
senderLongitude: existing?.senderLongitude,
recipientLatitude: existing?.recipientLatitude,
recipientLongitude: existing?.recipientLongitude,
); );
await _saveHistory( await _saveHistory(
@@ -172,6 +183,10 @@ class PathHistoryService {
PathSelection selection, { PathSelection selection, {
required bool success, required bool success,
int? roundTripTimeMs, int? roundTripTimeMs,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) async { }) async {
await initialize(); await initialize();
final history = _historyFor(contactPublicKeyHex); final history = _historyFor(contactPublicKeyHex);
@@ -206,6 +221,13 @@ class PathHistoryService {
? (roundTripTimeMs ?? existing?.lastRoundTripTimeMs ?? 0) ? (roundTripTimeMs ?? existing?.lastRoundTripTimeMs ?? 0)
: (existing?.lastRoundTripTimeMs ?? 0), : (existing?.lastRoundTripTimeMs ?? 0),
lastUsedAt: DateTime.now(), lastUsedAt: DateTime.now(),
lastSucceededAt: success ? DateTime.now() : existing?.lastSucceededAt,
senderLatitude: success ? senderLatitude : existing?.senderLatitude,
senderLongitude: success ? senderLongitude : existing?.senderLongitude,
recipientLatitude:
success ? recipientLatitude : existing?.recipientLatitude,
recipientLongitude:
success ? recipientLongitude : existing?.recipientLongitude,
); );
await _saveHistory( await _saveHistory(
contactPublicKeyHex, contactPublicKeyHex,
@@ -215,6 +237,54 @@ class PathHistoryService {
); );
} }
Future<PathSelection?> getLastSuccessfulDirectSelection(
Contact contact, {
String? excludeSignature,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) async {
await initialize();
final history = _historyFor(contact.publicKeyHex);
final ranked = history.directPaths
.where(
(record) =>
record.successCount > 0 &&
record.lastSucceededAt != null &&
record.signature != excludeSignature,
)
.toList()
..sort((a, b) {
final locationCompare = _compareLocationFit(
a,
b,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
if (locationCompare != 0) return locationCompare;
final succeededCompare = b.lastSucceededAt!.compareTo(
a.lastSucceededAt!,
);
if (succeededCompare != 0) return succeededCompare;
return _comparePathRecords(a, b);
});
if (ranked.isEmpty) {
return null;
}
final record = ranked.first;
return PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList(record.pathBytes),
hopCount: record.hopCount,
hashSize: record.hashSize,
);
}
ContactPathHistory historyFor(String contactPublicKeyHex) { ContactPathHistory historyFor(String contactPublicKeyHex) {
return _cache[contactPublicKeyHex] ?? return _cache[contactPublicKeyHex] ??
ContactPathHistory.empty(contactPublicKeyHex); ContactPathHistory.empty(contactPublicKeyHex);
@@ -279,4 +349,68 @@ class PathHistoryService {
String _signature(Uint8List bytes) => String _signature(Uint8List bytes) =>
bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
int _compareLocationFit(
PathRecord a,
PathRecord b, {
required double? senderLatitude,
required double? senderLongitude,
required double? recipientLatitude,
required double? recipientLongitude,
}) {
final aDistance = _locationDistanceScore(
a,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
final bDistance = _locationDistanceScore(
b,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
return aDistance.compareTo(bDistance);
}
double _locationDistanceScore(
PathRecord record, {
required double? senderLatitude,
required double? senderLongitude,
required double? recipientLatitude,
required double? recipientLongitude,
}) {
var total = 0.0;
var matched = false;
if (senderLatitude != null &&
senderLongitude != null &&
record.senderLatitude != null &&
record.senderLongitude != null) {
matched = true;
total += Geolocator.distanceBetween(
senderLatitude,
senderLongitude,
record.senderLatitude!,
record.senderLongitude!,
);
}
if (recipientLatitude != null &&
recipientLongitude != null &&
record.recipientLatitude != null &&
record.recipientLongitude != null) {
matched = true;
total += Geolocator.distanceBetween(
recipientLatitude,
recipientLongitude,
record.recipientLatitude!,
record.recipientLongitude!,
);
}
return matched ? total : double.infinity;
}
} }

View File

@@ -0,0 +1,86 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart';
/// Stores per-channel region scope settings.
///
/// Each channel can optionally have a region scope assigned. When set, the app
/// will set the flood scope on the device before sending channel messages so
/// that repeaters outside the region won't forward them.
class RegionScopePreferences {
static const String _namePrefix = 'region_scope_name_';
static const String _keyPrefix = 'region_scope_key_';
/// Get the saved scope for a channel.
/// Returns null if no scope is set.
static Future<({String name, Uint8List key})?> getScope(
int channelIdx,
) async {
final prefs = await SharedPreferences.getInstance();
final name = prefs.getString(
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
);
final keyBase64 = prefs.getString(
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
);
if (name == null || keyBase64 == null) return null;
try {
return (name: name, key: Uint8List.fromList(base64.decode(keyBase64)));
} catch (_) {
return null;
}
}
/// Save a region scope for a channel.
static Future<void> setScope(
int channelIdx,
String name,
Uint8List key,
) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
name,
);
await prefs.setString(
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
base64.encode(key),
);
}
/// Clear the region scope for a channel.
static Future<void> clearScope(int channelIdx) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
);
await prefs.remove(
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
);
}
/// Clear all region scopes (all channels).
static Future<void> clearAllScopes() async {
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys();
for (final key in keys) {
if (key.contains(_namePrefix) || key.contains(_keyPrefix)) {
await prefs.remove(key);
}
}
}
/// Derive the 16-byte transport key for a region name.
///
/// Matches firmware `TransportKeyStore::getAutoKeyFor`:
/// `SHA256("#regionname")` → first 16 bytes.
/// The name must include the leading `#` (auto-prepended if missing).
static Uint8List deriveRegionKey(String regionName) {
final normalized =
regionName.startsWith('#') ? regionName : '#$regionName';
final digest = sha256.convert(utf8.encode(normalized));
return Uint8List.fromList(digest.bytes.sublist(0, 16));
}
}

View File

@@ -4,10 +4,14 @@ import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../screens/discovery_screen.dart';
import '../services/network_scanner_service.dart'; import '../services/network_scanner_service.dart';
import '../services/profile_workspace_coordinator.dart'; import '../services/profile_workspace_coordinator.dart';
import '../services/serial/serial_transport.dart'; import '../services/serial/serial_transport.dart';
enum _ConnectionDialogResult { connected }
Future<void> _initializeConnectedWorkspace({ Future<void> _initializeConnectedWorkspace({
required ProfileWorkspaceCoordinator profileWorkspaceCoordinator, required ProfileWorkspaceCoordinator profileWorkspaceCoordinator,
required AppProvider appProvider, required AppProvider appProvider,
@@ -16,6 +20,103 @@ Future<void> _initializeConnectedWorkspace({
await appProvider.initialize(); await appProvider.initialize();
} }
String _normalizeConnectionError(Object error) {
var message = error.toString();
if (message.startsWith('Exception: ')) {
message = message.substring('Exception: '.length);
}
if (message.startsWith('Connection failed: Exception: ')) {
return message.substring('Connection failed: Exception: '.length);
}
if (message.startsWith('Connection failed: ')) {
return message.substring('Connection failed: '.length);
}
return message;
}
void _showConnectionErrorSnackBar(BuildContext context, Object error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_normalizeConnectionError(error)),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
),
);
}
Future<bool> showConnectionDialogFlow(
BuildContext context, {
Color? backgroundColor,
bool offerPostConnectRepeaterDiscovery = false,
}) async {
final result = await showModalBottomSheet<_ConnectionDialogResult>(
context: context,
isScrollControlled: true,
backgroundColor: backgroundColor,
builder: (context) => const ConnectionDialog(),
);
if (result != _ConnectionDialogResult.connected || !context.mounted) {
return result == _ConnectionDialogResult.connected;
}
try {
await _initializeConnectedWorkspace(
profileWorkspaceCoordinator: context.read<ProfileWorkspaceCoordinator>(),
appProvider: context.read<AppProvider>(),
);
} catch (error) {
if (context.mounted) {
_showConnectionErrorSnackBar(context, error);
}
}
if (!context.mounted) {
return true;
}
if (!offerPostConnectRepeaterDiscovery) {
return true;
}
final contactsProvider = context.read<ContactsProvider>();
if (contactsProvider.repeaters.isNotEmpty) {
return true;
}
final l10n = AppLocalizations.of(context)!;
final openDiscovery = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.postConnectDiscoveryTitle),
content: Text(l10n.postConnectDiscoveryDescription),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.continue_),
),
FilledButton.icon(
onPressed: () => Navigator.of(dialogContext).pop(true),
icon: const Icon(Icons.router_outlined),
label: Text(l10n.discoverRepeaters),
),
],
),
);
if (openDiscovery != true || !context.mounted) {
return true;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
const DiscoveryScreen(autoDiscoverRepeatersOnOpen: true),
),
);
return true;
}
/// Connection Dialog with tabs for BLE devices and Network servers /// Connection Dialog with tabs for BLE devices and Network servers
class ConnectionDialog extends StatefulWidget { class ConnectionDialog extends StatefulWidget {
const ConnectionDialog({super.key}); const ConnectionDialog({super.key});
@@ -33,6 +134,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
int _scannedCount = 0; int _scannedCount = 0;
int _totalToScan = 0; int _totalToScan = 0;
int _lastTabIndex = 0; int _lastTabIndex = 0;
bool _hasRequestedBleScan = false;
String? _connectingToServerKey; String? _connectingToServerKey;
String? _connectingBleDeviceId; String? _connectingBleDeviceId;
@@ -40,10 +142,6 @@ class _ConnectionDialogState extends State<ConnectionDialog>
if (_tabController.index == _lastTabIndex) return; if (_tabController.index == _lastTabIndex) return;
_lastTabIndex = _tabController.index; _lastTabIndex = _tabController.index;
if (_tabController.index == 0) {
_refreshBleDevices();
}
if (_tabController.index == 1) { if (_tabController.index == 1) {
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) { if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
setState(() { setState(() {
@@ -65,11 +163,6 @@ class _ConnectionDialogState extends State<ConnectionDialog>
listen: false, listen: false,
); );
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_refreshBleDevices();
});
_networkScanner.onServerDiscovered = (server) { _networkScanner.onServerDiscovered = (server) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -110,6 +203,13 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
Future<void> _refreshBleDevices() async { Future<void> _refreshBleDevices() async {
if (!_hasRequestedBleScan && mounted) {
setState(() {
_hasRequestedBleScan = true;
});
} else {
_hasRequestedBleScan = true;
}
await _connectionProvider.stopScan(); await _connectionProvider.stopScan();
if (!mounted) return; if (!mounted) return;
await _connectionProvider.startScan(); await _connectionProvider.startScan();
@@ -121,6 +221,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
return Colors.red; return Colors.red;
} }
void _closeOnSuccessfulConnection() {
if (!mounted) return;
Navigator.of(context).pop(_ConnectionDialogResult.connected);
}
void _showConnectionError(Object error) {
if (!mounted) return;
_showConnectionErrorSnackBar(context, error);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connectionProvider = context.watch<ConnectionProvider>(); final connectionProvider = context.watch<ConnectionProvider>();
@@ -401,11 +511,13 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) { Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) {
final l10n = AppLocalizations.of(context)!;
return Column( return Column(
children: [ children: [
_buildSectionBanner( _buildSectionBanner(
icon: Icons.bluetooth_searching_rounded, icon: Icons.bluetooth_searching_rounded,
message: AppLocalizations.of(context)!.defaultPinInfo, message: l10n.defaultPinInfo,
onRefresh: _refreshBleDevices, onRefresh: _refreshBleDevices,
), ),
if (connectionProvider.error != null) if (connectionProvider.error != null)
@@ -418,8 +530,10 @@ class _ConnectionDialogState extends State<ConnectionDialog>
: connectionProvider.scannedDevices.isEmpty : connectionProvider.scannedDevices.isEmpty
? _buildEmptyState( ? _buildEmptyState(
icon: Icons.bluetooth_searching_rounded, icon: Icons.bluetooth_searching_rounded,
title: AppLocalizations.of(context)!.noDevicesFound, title: _hasRequestedBleScan
actionLabel: AppLocalizations.of(context)!.scanAgain, ? l10n.noDevicesFound
: 'Press scan to search for nearby devices',
actionLabel: _hasRequestedBleScan ? l10n.scanAgain : 'Scan',
onAction: _refreshBleDevices, onAction: _refreshBleDevices,
) )
: ListView.builder( : ListView.builder(
@@ -434,25 +548,25 @@ class _ConnectionDialogState extends State<ConnectionDialog>
final isConnecting = _connectingBleDeviceId == deviceId; final isConnecting = _connectingBleDeviceId == deviceId;
Future<void> connectBle() async { Future<void> connectBle() async {
final appProvider = context.read<AppProvider>();
final profileWorkspaceCoordinator = context
.read<ProfileWorkspaceCoordinator>();
setState(() { setState(() {
_connectingBleDeviceId = deviceId; _connectingBleDeviceId = deviceId;
}); });
try { try {
Navigator.pop(context);
final success = await connectionProvider.connect( final success = await connectionProvider.connect(
device, device,
); );
if (success && if (!success) {
connectionProvider.deviceInfo.isConnected) { final name = device.platformName.isNotEmpty
await _initializeConnectedWorkspace( ? device.platformName
profileWorkspaceCoordinator: : 'device';
profileWorkspaceCoordinator, throw Exception(
appProvider: appProvider, connectionProvider.error ??
'Failed to connect to $name',
); );
} }
_closeOnSuccessfulConnection();
} catch (error) {
_showConnectionError(error);
} finally { } finally {
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -547,11 +661,6 @@ class _ConnectionDialogState extends State<ConnectionDialog>
Future<void> connectServer() async { Future<void> connectServer() async {
final connectionProvider = context final connectionProvider = context
.read<ConnectionProvider>(); .read<ConnectionProvider>();
final appProvider = context.read<AppProvider>();
final profileWorkspaceCoordinator = context
.read<ProfileWorkspaceCoordinator>();
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
setState(() { setState(() {
_connectingToServerKey = serverKey; _connectingToServerKey = serverKey;
@@ -567,52 +676,23 @@ class _ConnectionDialogState extends State<ConnectionDialog>
); );
} }
await connectionProvider.connectTcp( final success = await connectionProvider.connectTcp(
server.ipAddress, server.ipAddress,
server.port, server.port,
); );
await _initializeConnectedWorkspace( if (!success) {
profileWorkspaceCoordinator: throw Exception(
profileWorkspaceCoordinator, connectionProvider.error ??
appProvider: appProvider, 'Failed to connect to ${server.ipAddress}:${server.port}',
); );
if (mounted) {
navigator.pop();
} }
_closeOnSuccessfulConnection();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_connectingToServerKey = null; _connectingToServerKey = null;
}); });
_showConnectionError(e);
var errorMessage = e.toString();
if (errorMessage.startsWith('Exception: ')) {
errorMessage = errorMessage.substring(
'Exception: '.length,
);
}
if (errorMessage.startsWith(
'Connection failed: Exception: ',
)) {
errorMessage = errorMessage.substring(
'Connection failed: Exception: '.length,
);
} else if (errorMessage.startsWith(
'Connection failed: ',
)) {
errorMessage = errorMessage.substring(
'Connection failed: '.length,
);
}
messenger.showSnackBar(
SnackBar(
content: Text(errorMessage),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
),
);
} }
} }
@@ -679,8 +759,10 @@ class _ConnectionDialogState extends State<ConnectionDialog>
actionLabel: actionLabel, actionLabel: actionLabel,
onAction: onAction, onAction: onAction,
), ),
onConnected: () { onConnected: (result) {
if (mounted) Navigator.of(context).pop(); if (mounted) {
Navigator.of(context).pop(result);
}
}, },
); );
} }
@@ -706,7 +788,7 @@ typedef _EmptyStateBuilder =
}); });
class _SerialDeviceList extends StatefulWidget { class _SerialDeviceList extends StatefulWidget {
final VoidCallback onConnected; final ValueChanged<_ConnectionDialogResult> onConnected;
final _TransportCardBuilder buildTransportCard; final _TransportCardBuilder buildTransportCard;
final _EmptyStateBuilder buildEmptyState; final _EmptyStateBuilder buildEmptyState;
@@ -801,9 +883,6 @@ class _SerialDeviceListState extends State<_SerialDeviceList> {
setState(() => _isConnecting = true); setState(() => _isConnecting = true);
try { try {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final appProvider = context.read<AppProvider>();
final profileWorkspaceCoordinator = context
.read<ProfileWorkspaceCoordinator>();
final connection = await _transport.connect(device); final connection = await _transport.connect(device);
final success = await connectionProvider.connectSerial( final success = await connectionProvider.connectSerial(
service: connection.service, service: connection.service,
@@ -814,11 +893,7 @@ class _SerialDeviceListState extends State<_SerialDeviceList> {
if (!mounted) return; if (!mounted) return;
if (success) { if (success) {
await _initializeConnectedWorkspace( widget.onConnected(_ConnectionDialogResult.connected);
profileWorkspaceCoordinator: profileWorkspaceCoordinator,
appProvider: appProvider,
);
widget.onConnected();
} else { } else {
await connection.disconnect(); await connection.disconnect();
connection.service.dispose(); connection.service.dispose();

View File

@@ -157,6 +157,7 @@ class ContactTile extends StatelessWidget {
icon: Icons.folder_copy_outlined, icon: Icons.folder_copy_outlined,
label: label, label: label,
), ),
..._buildSignalPills(context),
], ],
), ),
if (location != null) ...[ if (location != null) ...[
@@ -324,6 +325,34 @@ class ContactTile extends StatelessWidget {
); );
} }
List<Widget> _buildSignalPills(BuildContext context) {
if (!contact.isRepeater && !contact.isSensor) return [];
final contactsProvider = context.read<ContactsProvider>();
final advert = contactsProvider.pendingAdvertByKey(contact.publicKey);
if (advert == null) return [];
final pills = <Widget>[];
if (advert.rxRssiDbm != null) {
pills.add(
_buildMetaPill(
context,
icon: Icons.arrow_downward_rounded,
label: '${advert.rxRssiDbm} dBm',
),
);
}
if (advert.repeaterLastRssi != null) {
pills.add(
_buildMetaPill(
context,
icon: Icons.arrow_upward_rounded,
label: '${advert.repeaterLastRssi} dBm',
),
);
}
return pills;
}
Widget _buildCompactSubtitle(BuildContext context, String? distanceText) { Widget _buildCompactSubtitle(BuildContext context, String? distanceText) {
final location = contact.displayLocation; final location = contact.displayLocation;
final compactPills = <Widget>[ final compactPills = <Widget>[
@@ -338,6 +367,7 @@ class ContactTile extends StatelessWidget {
icon: Icons.location_disabled_outlined, icon: Icons.location_disabled_outlined,
label: AppLocalizations.of(context)!.noGpsData, label: AppLocalizations.of(context)!.noGpsData,
), ),
..._buildSignalPills(context),
]; ];
return Padding( return Padding(

View File

@@ -1,387 +0,0 @@
import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart';
import '../../l10n/app_localizations.dart';
class SpectrumScanPanel extends StatelessWidget {
final ThemeData theme;
final bool scanSupported;
final bool isRunning;
final double rangeMinMhz;
final double rangeMaxMhz;
final RangeValues rangeValues;
final double bandwidthKhz;
final int? selectedFrequencyKhz;
final List<SpectrumScanCandidate> graphCandidates;
final List<SpectrumScanCandidate> selectableCandidates;
final ValueChanged<RangeValues> onRangeChanged;
final ValueChanged<int?> onCandidateChanged;
final VoidCallback onRunScan;
final VoidCallback onApplySelected;
const SpectrumScanPanel({
super.key,
required this.theme,
required this.scanSupported,
required this.isRunning,
required this.rangeMinMhz,
required this.rangeMaxMhz,
required this.rangeValues,
required this.bandwidthKhz,
required this.selectedFrequencyKhz,
required this.graphCandidates,
required this.selectableCandidates,
required this.onRangeChanged,
required this.onCandidateChanged,
required this.onRunScan,
required this.onApplySelected,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.45,
),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.tune, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
'Power Scan',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
FilledButton.icon(
onPressed: scanSupported && !isRunning ? onRunScan : null,
icon: isRunning
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.radar),
label: Text(
scanSupported
? (isRunning ? 'Scanning' : 'Scan')
: 'Unavailable',
),
),
],
),
const SizedBox(height: 8),
Text(
scanSupported
? 'Full range with bandwidth footprint. Firmware enforces hardware band limits and pauses the mesh while scanning.'
: 'Full range with bandwidth footprint. This companion does not support spectrum scan mode, so scanning is disabled.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
_FrequencyRangePreview(
minMhz: rangeMinMhz,
maxMhz: rangeMaxMhz,
selectedRange: rangeValues,
selectedBandwidthKhz: bandwidthKhz,
selectedFrequencyKhz: selectedFrequencyKhz,
candidates: graphCandidates,
),
const SizedBox(height: 10),
Wrap(
spacing: 14,
runSpacing: 6,
children: [
_LegendChip(
color: theme.colorScheme.primary,
label: AppLocalizations.of(context)!.quiet,
),
_LegendChip(
color: Colors.orange,
label: AppLocalizations.of(context)!.moderate,
),
_LegendChip(
color: theme.colorScheme.error,
label: AppLocalizations.of(context)!.busy,
),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${rangeValues.start.toStringAsFixed(3)} MHz',
style: theme.textTheme.labelMedium,
),
Text(
'${rangeValues.end.toStringAsFixed(3)} MHz',
style: theme.textTheme.labelMedium,
),
],
),
RangeSlider(
values: rangeValues,
min: rangeMinMhz,
max: rangeMaxMhz,
divisions: (((rangeMaxMhz - rangeMinMhz) * 20).round()).clamp(
1,
400,
),
labels: RangeLabels(
rangeValues.start.toStringAsFixed(3),
rangeValues.end.toStringAsFixed(3),
),
onChanged: onRangeChanged,
),
if (selectableCandidates.isEmpty) ...[
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Text(
scanSupported
? 'No scan results yet. Adjust the range and run a scan to populate candidate frequencies.'
: 'Spectrum preview only. This companion can display the configured span, but cannot scan for open channels.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
] else ...[
const SizedBox(height: 10),
DropdownButtonFormField<int>(
initialValue: selectedFrequencyKhz,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Candidate frequency',
border: OutlineInputBorder(),
helperText: 'Best frequencies for the current bandwidth',
),
items: selectableCandidates.map((candidate) {
return DropdownMenuItem<int>(
value: candidate.centerFrequencyKhz,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz',
overflow: TextOverflow.ellipsis,
),
Text(
'${candidate.occupancyPercent}% occupied | peak ${candidate.peakRssiDbm} dBm',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
],
),
);
}).toList(),
selectedItemBuilder: (context) {
return selectableCandidates.map((candidate) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
'${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz',
overflow: TextOverflow.ellipsis,
),
);
}).toList();
},
onChanged: onCandidateChanged,
),
const SizedBox(height: 10),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: selectedFrequencyKhz == null
? null
: onApplySelected,
icon: Icon(Icons.north_east),
label: Text(AppLocalizations.of(context)!.useSelectedFrequency),
),
),
],
],
),
);
}
}
class _LegendChip extends StatelessWidget {
final Color color;
final String label;
const _LegendChip({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.labelMedium),
],
);
}
}
class _FrequencyRangePreview extends StatelessWidget {
final double minMhz;
final double maxMhz;
final RangeValues selectedRange;
final double selectedBandwidthKhz;
final int? selectedFrequencyKhz;
final List<SpectrumScanCandidate> candidates;
const _FrequencyRangePreview({
required this.minMhz,
required this.maxMhz,
required this.selectedRange,
required this.selectedBandwidthKhz,
required this.selectedFrequencyKhz,
required this.candidates,
});
double _positionFor(double mhz) {
final span = maxMhz - minMhz;
if (span <= 0) return 0;
return ((mhz - minMhz) / span).clamp(0.0, 1.0);
}
Color _candidateColor(BuildContext context, int occupancyPercent) {
final scheme = Theme.of(context).colorScheme;
if (occupancyPercent <= 10) return scheme.primary;
if (occupancyPercent <= 35) return Colors.orange;
return scheme.error;
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final spanMhz = maxMhz - minMhz;
final selectedFreqMhz = selectedFrequencyKhz != null
? selectedFrequencyKhz! / 1000.0
: null;
final bwMhz = selectedBandwidthKhz / 1000.0;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final rangeLeft = _positionFor(selectedRange.start) * width;
final rangeRight = _positionFor(selectedRange.end) * width;
double? bwLeft;
double? bwWidth;
if (selectedFreqMhz != null && spanMhz > 0) {
bwLeft = _positionFor(selectedFreqMhz - (bwMhz / 2)) * width;
final bwRight = _positionFor(selectedFreqMhz + (bwMhz / 2)) * width;
bwWidth = (bwRight - bwLeft).clamp(4.0, width);
}
return Container(
height: 108,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: scheme.surfaceContainerLow,
border: Border.all(color: scheme.outlineVariant),
),
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 12,
),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: scheme.surfaceContainerHigh,
),
),
),
),
Positioned(
left: rangeLeft,
top: 12,
width: (rangeRight - rangeLeft).clamp(8.0, width),
height: 56,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: scheme.primaryContainer,
border: Border.all(color: scheme.primary),
),
),
),
if (bwLeft != null && bwWidth != null)
Positioned(
left: bwLeft,
top: 28,
width: bwWidth,
height: 24,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: scheme.tertiaryContainer,
border: Border.all(color: scheme.tertiary),
),
),
),
for (final candidate in candidates)
Positioned(
left: (_positionFor(candidate.centerFrequencyMhz) * width)
.clamp(10.0, width - 18.0),
top: 72,
child: Column(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _candidateColor(
context,
candidate.occupancyPercent,
),
),
),
const SizedBox(height: 4),
Text(
candidate.centerFrequencyMhz.toStringAsFixed(3),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
],
),
);
},
);
}
}

View File

@@ -1,9 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:flutter_compass/flutter_compass.dart'; import 'package:flutter_compass/flutter_compass.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../../services/compass_support.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../models/sar_marker.dart'; import '../../models/sar_marker.dart';
import '../common/location_display.dart'; import '../common/location_display.dart';
@@ -90,17 +92,33 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
}); });
} }
// Subscribe to compass updates // Subscribe to compass updates on supported targets only.
final compassStream = FlutterCompass.events; final compassStream = CompassSupport.isAvailable
? FlutterCompass.events
: null;
if (compassStream != null) { if (compassStream != null) {
_compassSubscription = compassStream.listen((event) { _compassSubscription = compassStream.listen(
if (mounted && event.heading != null) { (event) {
setState(() { if (mounted && event.heading != null) {
_currentHeading = event.heading; setState(() {
_compassAccuracy = event.accuracy; _currentHeading = event.heading;
}); _compassAccuracy = event.accuracy;
} });
}); }
},
onError: (Object error, StackTrace stackTrace) {
if (!mounted) {
return;
}
if (error is MissingPluginException) {
debugPrint('Compass plugin is unavailable on this platform');
return;
}
debugPrint('Compass dialog stream error: $error');
},
);
} }
// Subscribe to position updates // Subscribe to position updates

View File

@@ -12,6 +12,7 @@ import '../../models/sar_template.dart';
import '../../models/map_drawing.dart'; import '../../models/map_drawing.dart';
import '../../models/map_coordinate_space.dart'; import '../../models/map_coordinate_space.dart';
import '../../providers/messages_provider.dart'; import '../../providers/messages_provider.dart';
import '../../providers/channels_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/drawing_provider.dart'; import '../../providers/drawing_provider.dart';
@@ -1955,10 +1956,12 @@ class _MessageBubbleState extends State<MessageBubble> {
final isSarMarker = message.isSarMarker; final isSarMarker = message.isSarMarker;
final isDarkMode = Theme.of(context).brightness == Brightness.dark; final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final messageFontScale = context.watch<AppProvider>().messageFontScale; final messageFontScale = context.watch<AppProvider>().messageFontScale;
final l10n = AppLocalizations.of(context)!;
// Determine if this is own message // Determine if this is own message
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
final channelsProvider = context.watch<ChannelsProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey; final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage = final isOwnMessage =
message.isSentMessage || message.isFromSelf(selfPublicKey); message.isSentMessage || message.isFromSelf(selfPublicKey);
@@ -2004,7 +2007,7 @@ class _MessageBubbleState extends State<MessageBubble> {
// Get rich display name (with emoji if available) // Get rich display name (with emoji if available)
final displayName = isOwnMessage final displayName = isOwnMessage
? AppLocalizations.of(context)!.you ? l10n.you
: message.getRichDisplayName(senderContact); : message.getRichDisplayName(senderContact);
// Look up destination/source display labels for direct/channel messages // Look up destination/source display labels for direct/channel messages
@@ -2039,15 +2042,30 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
} }
} else if (message.isChannelMessage) { } else if (message.isChannelMessage) {
if (message.channelIdx == 0) { final channelIdx = message.channelIdx ?? 0;
channelDisplayName = AppLocalizations.of(context)!.publicChannel; if (channelIdx == 0) {
channelDisplayName = l10n.publicChannel;
} else { } else {
final channelContact = contactsProvider.channels.where((c) { final channelContact = contactsProvider.channels.where((c) {
return c.publicKey.length > 1 && c.publicKey[1] == message.channelIdx; return c.publicKey.length > 1 && c.publicKey[1] == channelIdx;
}).firstOrNull; }).firstOrNull;
final syncedChannel = channelsProvider.getChannel(channelIdx);
final syncedChannelDisplayName =
syncedChannel != null && syncedChannel.hasCustomName
? syncedChannel.displayName
: null;
final contactChannelDisplayName = channelContact
?.getLocalizedDisplayName(context)
.trim();
channelDisplayName = channelDisplayName =
channelContact?.getLocalizedDisplayName(context) ?? syncedChannelDisplayName ??
'${AppLocalizations.of(context)!.channel} ${message.channelIdx}'; (contactChannelDisplayName != null &&
contactChannelDisplayName.isNotEmpty
? contactChannelDisplayName
: null) ??
syncedChannel?.displayName ??
'${l10n.channel} $channelIdx';
} }
if (isOwnMessage) { if (isOwnMessage) {
@@ -2057,14 +2075,14 @@ class _MessageBubbleState extends State<MessageBubble> {
final recipientSubtitle = final recipientSubtitle =
isOwnMessage && message.isChannelMessage && recipientDisplayName != null isOwnMessage && message.isChannelMessage && recipientDisplayName != null
? '${AppLocalizations.of(context)!.channel}: $recipientDisplayName' ? '${l10n.channel}: $recipientDisplayName'
: recipientDisplayName; : recipientDisplayName;
final directCounterpartLabel = !message.isChannelMessage final directCounterpartLabel = !message.isChannelMessage
? (isOwnMessage ? recipientSubtitle : AppLocalizations.of(context)!.you) ? (isOwnMessage ? recipientSubtitle : l10n.you)
: null; : null;
final receivedChannelSubtitle = final receivedChannelSubtitle =
!isOwnMessage && message.isChannelMessage && channelDisplayName != null !isOwnMessage && message.isChannelMessage && channelDisplayName != null
? '${AppLocalizations.of(context)!.channel}: $channelDisplayName' ? '${l10n.channel}: $channelDisplayName'
: null; : null;
final shouldFloatBubble = widget.isCompact; final shouldFloatBubble = widget.isCompact;

View File

@@ -27,6 +27,8 @@ class MessagesComposer extends StatelessWidget {
final Future<void> Function() onStartVoiceRecording; final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice; final Future<void> Function() onStopAndSendVoice;
final Future<void> Function() onSendMessage; final Future<void> Function() onSendMessage;
final String? regionScopeName;
final VoidCallback? onRegionScopeTap;
const MessagesComposer({ const MessagesComposer({
super.key, super.key,
@@ -49,6 +51,8 @@ class MessagesComposer extends StatelessWidget {
required this.onStartVoiceRecording, required this.onStartVoiceRecording,
required this.onStopAndSendVoice, required this.onStopAndSendVoice,
required this.onSendMessage, required this.onSendMessage,
this.regionScopeName,
this.onRegionScopeTap,
}); });
@override @override
@@ -102,6 +106,14 @@ class MessagesComposer extends StatelessWidget {
? onStopAndSendVoice ? onStopAndSendVoice
: onShowComposerActions, : onShowComposerActions,
), ),
if (regionScopeName != null &&
onRegionScopeTap != null) ...[
const SizedBox(width: 6),
_RegionScopeChip(
name: regionScopeName!,
onTap: onRegionScopeTap!,
),
],
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: _DestinationSelector( child: _DestinationSelector(
@@ -534,3 +546,52 @@ class _SendButton extends StatelessWidget {
); );
} }
} }
class _RegionScopeChip extends StatelessWidget {
final String name;
final VoidCallback onTap;
const _RegionScopeChip({required this.name, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.tertiary.withValues(alpha: 0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.language_rounded,
size: 16,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 80),
child: Text(
name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onTertiaryContainer,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../providers/messages_provider.dart';
import '../../utils/avatar_label_helper.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
enum _RecipientSortMode { activity, favorites, alphabetical } enum _RecipientSortMode { activity, favorites, alphabetical }
@@ -17,6 +20,10 @@ class RecipientSelectorSheet extends StatefulWidget {
final String? currentRecipientPublicKey; final String? currentRecipientPublicKey;
final bool showAllOption; final bool showAllOption;
final Function(String type, Contact? recipient) onSelect; final Function(String type, Contact? recipient) onSelect;
final MessagesProvider? messagesProvider;
/// Region scope names per channel index (e.g. {0: "#auckland"}).
final Map<int, String> channelRegionScopes;
const RecipientSelectorSheet({ const RecipientSelectorSheet({
super.key, super.key,
@@ -29,6 +36,8 @@ class RecipientSelectorSheet extends StatefulWidget {
this.currentRecipientPublicKey, this.currentRecipientPublicKey,
this.showAllOption = true, this.showAllOption = true,
required this.onSelect, required this.onSelect,
this.messagesProvider,
this.channelRegionScopes = const {},
}); });
@override @override
@@ -163,15 +172,162 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
} }
} }
String _channelSubtitle(BuildContext context, Contact channel) { MessagesProvider? _resolveMessagesProvider(BuildContext context) {
final l10n = AppLocalizations.of(context)!; if (widget.messagesProvider != null) {
if (channel.isPublicChannel) { return widget.messagesProvider;
return l10n.broadcastToAllNearby;
} }
try {
return Provider.of<MessagesProvider>(context);
} on ProviderNotFoundException {
return null;
}
}
String _formatRelativeTime(BuildContext context, DateTime when) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
_ChannelPreviewData _channelPreviewData(
BuildContext context,
Contact channel,
MessagesProvider? messagesProvider,
) {
if (messagesProvider == null) {
return const _ChannelPreviewData();
}
final lastActivityAt = messagesProvider.getLastActivityForDestination(
channel,
);
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final shortKey = channel.publicKeyShort.toUpperCase(); final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
return '${l10n.channel} $channelIdx$shortKey'; ..sort((a, b) => b.sentAt.compareTo(a.sentAt));
final participantNames = <String>[];
for (final message in channelMessages) {
final senderName = message.senderName?.trim();
if (senderName == null || senderName.isEmpty) {
continue;
}
if (!participantNames.contains(senderName)) {
participantNames.add(senderName);
}
}
return _ChannelPreviewData(
activityLabel: lastActivityAt == null
? null
: _formatRelativeTime(context, lastActivityAt),
participantNames: participantNames,
);
}
Contact? _findParticipantContact(String name) {
final normalizedName = name.trim();
for (final contact in widget.contacts) {
if (!contact.isChannel && contact.advName.trim() == normalizedName) {
return contact;
}
}
for (final contact in widget.contacts) {
if (!contact.isChannel && contact.displayName.trim() == normalizedName) {
return contact;
}
}
return null;
}
String _contactActivityLabel(
BuildContext context,
Contact contact,
MessagesProvider? messagesProvider,
) {
final lastActivityAt =
messagesProvider?.getLastActivityForDestination(contact) ??
contact.lastSeenTime;
return _formatRelativeTime(context, lastActivityAt);
}
Widget _buildTextSubtitle(
BuildContext context,
Contact contact,
String subtitle,
) {
final colorScheme = Theme.of(context).colorScheme;
return Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontFamily: contact.isChannel ? null : 'monospace',
),
);
}
Widget _buildChannelSubtitle(
BuildContext context,
Contact channel,
_ChannelPreviewData previewData,
) {
final colorScheme = Theme.of(context).colorScheme;
if (previewData.participantNames.isEmpty) {
return Text(
'No recent chatters',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
);
}
return Row(
children: [
_ParticipantAvatarStack(
key: Key('channel-participants-${channel.publicKeyHex}'),
names: previewData.participantNames,
contactForName: _findParticipantContact,
),
],
);
}
Widget _buildChannelRecipientCard(
BuildContext context,
Contact channel,
MessagesProvider? messagesProvider,
) {
final previewData = _channelPreviewData(context, channel, messagesProvider);
return _buildRecipientCard(
context: context,
type: 'channel',
contact: channel,
title: channel.getLocalizedDisplayName(context),
subtitle: _buildChannelSubtitle(context, channel, previewData),
unreadCount: _unreadFor(channel),
isSelected: _isSelected('channel', channel),
compact: true,
activityLabel: previewData.activityLabel,
onTap: () {
widget.onSelect('channel', channel);
Navigator.pop(context);
},
);
} }
bool _isDenseSection(String type) => type == 'channel' || type == 'contact'; bool _isDenseSection(String type) => type == 'channel' || type == 'contact';
@@ -180,6 +336,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final messagesProvider = _resolveMessagesProvider(context);
final filteredContacts = _filterAndSortContacts(widget.contacts); final filteredContacts = _filterAndSortContacts(widget.contacts);
final filteredRooms = _filterAndSortContacts(widget.rooms); final filteredRooms = _filterAndSortContacts(widget.rooms);
final filteredChannels = _filterAndSortContacts( final filteredChannels = _filterAndSortContacts(
@@ -326,19 +483,10 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
emptyLabel: l10n.noChannelsFound, emptyLabel: l10n.noChannelsFound,
children: [ children: [
for (final channel in filteredChannels) for (final channel in filteredChannels)
_buildRecipientCard( _buildChannelRecipientCard(
context: context, context,
type: 'channel', channel,
contact: channel, messagesProvider,
title: channel.getLocalizedDisplayName(context),
subtitle: _channelSubtitle(context, channel),
unreadCount: _unreadFor(channel),
isSelected: _isSelected('channel', channel),
compact: true,
onTap: () {
widget.onSelect('channel', channel);
Navigator.pop(context);
},
), ),
], ],
), ),
@@ -357,7 +505,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
type: 'contact', type: 'contact',
contact: contact, contact: contact,
title: contact.displayName, title: contact.displayName,
subtitle: contact.publicKeyShort, activityLabel: _contactActivityLabel(
context,
contact,
messagesProvider,
),
unreadCount: _unreadFor(contact), unreadCount: _unreadFor(contact),
isSelected: _isSelected('contact', contact), isSelected: _isSelected('contact', contact),
compact: true, compact: true,
@@ -383,7 +535,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
type: 'room', type: 'room',
contact: room, contact: room,
title: room.displayName, title: room.displayName,
subtitle: room.publicKeyShort, subtitle: _buildTextSubtitle(
context,
room,
room.publicKeyShort,
),
unreadCount: _unreadFor(room), unreadCount: _unreadFor(room),
isSelected: _isSelected('room', room), isSelected: _isSelected('room', room),
onTap: () { onTap: () {
@@ -681,10 +837,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
required String type, required String type,
required Contact contact, required Contact contact,
required String title, required String title,
required String subtitle, Widget? subtitle,
required int unreadCount, required int unreadCount,
required bool isSelected, required bool isSelected,
bool compact = false, bool compact = false,
String? activityLabel,
required VoidCallback onTap, required VoidCallback onTap,
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -748,52 +905,70 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Flexible( Expanded(
child: Text( child: Row(
title, children: [
maxLines: 1, Flexible(
overflow: TextOverflow.ellipsis, child: Text(
style: Theme.of(context).textTheme.titleSmall title,
?.copyWith( maxLines: 1,
fontWeight: FontWeight.w800, overflow: TextOverflow.ellipsis,
letterSpacing: -0.2, style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
),
), ),
),
if (contact.isPublicChannel) ...[
SizedBox(width: compact ? 6 : 8),
Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 7 : 8,
vertical: compact ? 2 : 3,
),
decoration: BoxDecoration(
color: accentColor.withValues(
alpha: 0.10,
),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'Public',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: accentColor,
fontWeight: FontWeight.w800,
),
),
),
],
],
), ),
), ),
if (contact.isPublicChannel) ...[ if (activityLabel != null) ...[
SizedBox(width: compact ? 6 : 8), SizedBox(width: compact ? 8 : 10),
Container( Text(
padding: EdgeInsets.symmetric( activityLabel,
horizontal: compact ? 7 : 8, style: Theme.of(context).textTheme.labelSmall
vertical: compact ? 2 : 3, ?.copyWith(
), color: colorScheme.onSurfaceVariant,
decoration: BoxDecoration( fontWeight: FontWeight.w700,
color: accentColor.withValues(alpha: 0.10), ),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'Public',
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: accentColor,
fontWeight: FontWeight.w800,
),
),
), ),
], ],
], ],
), ),
SizedBox(height: compact ? 2 : 4), if (subtitle != null) ...[
Text( SizedBox(height: compact ? 2 : 4),
subtitle, subtitle,
maxLines: 1, ],
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontFamily: contact.isChannel ? null : 'monospace',
),
),
], ],
), ),
), ),
@@ -849,3 +1024,140 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
); );
} }
} }
class _ChannelPreviewData {
final String? activityLabel;
final List<String> participantNames;
const _ChannelPreviewData({
this.activityLabel,
this.participantNames = const <String>[],
});
}
class _ParticipantAvatarStack extends StatelessWidget {
final List<String> names;
final Contact? Function(String name) contactForName;
static const int _visibleCount = 4;
const _ParticipantAvatarStack({
super.key,
required this.names,
required this.contactForName,
});
@override
Widget build(BuildContext context) {
final visibleNames = names.take(_visibleCount).toList();
final overflowCount = names.length - visibleNames.length;
const avatarSize = 20.0;
const spacing = 14.0;
final itemCount = visibleNames.length + (overflowCount > 0 ? 1 : 0);
final width = itemCount == 0 ? 0.0 : avatarSize + (itemCount - 1) * spacing;
return SizedBox(
width: width,
height: avatarSize,
child: Stack(
clipBehavior: Clip.none,
children: [
for (var i = 0; i < visibleNames.length; i++)
Positioned(
left: i * spacing,
top: 0,
child: _ParticipantAvatar(
name: visibleNames[i],
contact: contactForName(visibleNames[i]),
),
),
if (overflowCount > 0)
Positioned(
left: visibleNames.length * spacing,
top: 0,
child: _ParticipantOverflowAvatar(count: overflowCount),
),
],
),
);
}
}
class _ParticipantOverflowAvatar extends StatelessWidget {
final int count;
const _ParticipantOverflowAvatar({required this.count});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.surface, width: 2),
),
alignment: Alignment.center,
child: Text(
'+$count',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
fontSize: 8,
),
),
);
}
}
class _ParticipantAvatar extends StatelessWidget {
final String name;
final Contact? contact;
const _ParticipantAvatar({required this.name, required this.contact});
@override
Widget build(BuildContext context) {
if (contact != null) {
final surfaceColor = Theme.of(context).colorScheme.surface;
return SizedBox(
width: 20,
height: 20,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: surfaceColor, width: 2),
),
child: Padding(
padding: const EdgeInsets.all(2),
child: ClipOval(child: ContactAvatar(contact: contact!, radius: 6)),
),
),
);
}
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.surface, width: 2),
),
alignment: Alignment.center,
child: Text(
AvatarLabelHelper.buildLabel(name),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onTertiaryContainer,
fontSize: 8,
),
),
);
}
}

View File

@@ -827,8 +827,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: main ref: a0deff8
resolved-ref: "71902401e9a6bc7d2ed1193f8961e7256646ab76" resolved-ref: a0deff80fcbca974f0e18fe47971b76bec583109
url: "https://github.com/dz0ny/meshcore_client.git" url: "https://github.com/dz0ny/meshcore_client.git"
source: git source: git
version: "0.1.0" version: "0.1.0"

View File

@@ -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: 2026.0322.1+38 version: 2026.0323.3+42
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -44,7 +44,7 @@ dependencies:
meshcore_client: meshcore_client:
git: git:
url: https://github.com/dz0ny/meshcore_client.git url: https://github.com/dz0ny/meshcore_client.git
ref: main ref: a0deff8
# Codec2 ultra-low-bitrate speech codec (FFI plugin) # Codec2 ultra-low-bitrate speech codec (FFI plugin)
codec2_flutter: codec2_flutter:

View File

@@ -781,6 +781,19 @@ void main() {
expect(updated.advLat, equals((44.123456 * 1e6).round())); expect(updated.advLat, equals((44.123456 * 1e6).round()));
expect(updated.advLon, equals((13.654321 * 1e6).round())); expect(updated.advLon, equals((13.654321 * 1e6).round()));
expect(updated.lastAdvert, equals(1700001234)); expect(updated.lastAdvert, equals(1700001234));
expect(updated.lastMod, equals(1700001234));
expect(
provider.estimatedLocationFor(updated.publicKeyHex),
isNotNull,
);
expect(
provider.estimatedLocationFor(updated.publicKeyHex)!.latitude,
closeTo(44.123456, 0.000001),
);
expect(
provider.estimatedLocationFor(updated.publicKeyHex)!.longitude,
closeTo(13.654321, 0.000001),
);
}); });
test('ignores unknown sender prefix safely', () { test('ignores unknown sender prefix safely', () {

View File

@@ -214,7 +214,7 @@ void main() {
}); });
}); });
test('channel warning clears when replay is deduped into sent bubble', () { test('channel warning clears when replay arrives after send', () {
fakeAsync((async) { fakeAsync((async) {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
@@ -239,11 +239,11 @@ void main() {
); );
expect(provider.hasChannelSendWarning('c-warn-replay'), isFalse); expect(provider.hasChannelSendWarning('c-warn-replay'), isFalse);
expect(provider.messages, hasLength(1)); expect(provider.messages, hasLength(2));
}); });
}); });
test('channel replay is deduped for self sender within repeat window', () { test('channel replay is kept separate for self sender within repeat window', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addSentMessage( provider.addSentMessage(
@@ -259,9 +259,10 @@ void main() {
), ),
); );
expect(provider.messages, hasLength(1)); expect(provider.messages, hasLength(2));
expect(provider.messages.single.id, equals('c-echo')); expect(provider.messages.first.id, equals('c-echo'));
expect(provider.messages.single.senderName, equals('dz0ny (SI)')); expect(provider.messages.last.id, equals('c-echo-incoming'));
expect(provider.messages.last.senderName, equals('dz0ny (SI)'));
}); });
test( test(
@@ -308,7 +309,7 @@ void main() {
expect(provider.messages, hasLength(2)); expect(provider.messages, hasLength(2));
}); });
test('channel replay can dedupe using lazily resolved self name', () { test('channel replay stays separate using lazily resolved self name', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.addSentMessage( provider.addSentMessage(
_buildSentChannelMessage(id: 'c-lazy', senderTimestamp: 1700000400), _buildSentChannelMessage(id: 'c-lazy', senderTimestamp: 1700000400),
@@ -324,11 +325,12 @@ void main() {
), ),
); );
expect(provider.messages, hasLength(1)); expect(provider.messages, hasLength(2));
expect(provider.messages.single.id, equals('c-lazy')); expect(provider.messages.first.id, equals('c-lazy'));
expect(provider.messages.last.id, equals('c-lazy-incoming'));
}); });
test('channel replay dedupes meshcore-prefixed self sender name', () { test('channel replay stays separate for meshcore-prefixed self sender name', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'MeshCore-dz0ny (SI)'; provider.resolveContactNameCallback = (_) => 'MeshCore-dz0ny (SI)';
provider.addSentMessage( provider.addSentMessage(
@@ -344,8 +346,9 @@ void main() {
), ),
); );
expect(provider.messages, hasLength(1)); expect(provider.messages, hasLength(2));
expect(provider.messages.single.id, equals('c-prefix')); expect(provider.messages.first.id, equals('c-prefix'));
expect(provider.messages.last.id, equals('c-prefix-incoming'));
}); });
test('duplicate incoming message increments received copy count', () { test('duplicate incoming message increments received copy count', () {

View File

@@ -38,6 +38,7 @@ Future<void> _pumpDiscoveryScreen(
WidgetTester tester, { WidgetTester tester, {
ContactsProvider? contactsProvider, ContactsProvider? contactsProvider,
ConnectionProvider? connectionProvider, ConnectionProvider? connectionProvider,
DiscoveryScreen screen = const DiscoveryScreen(),
}) async { }) async {
final resolvedContactsProvider = contactsProvider ?? ContactsProvider(); final resolvedContactsProvider = contactsProvider ?? ContactsProvider();
final resolvedConnectionProvider = final resolvedConnectionProvider =
@@ -56,7 +57,7 @@ Future<void> _pumpDiscoveryScreen(
child: MaterialApp( child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
home: const DiscoveryScreen(), home: screen,
), ),
), ),
); );
@@ -113,6 +114,21 @@ void main() {
}, },
); );
testWidgets('auto discovery can trigger repeater discovery on open', (
tester,
) async {
final connectionProvider = _FakeConnectionProvider(isConnected: true);
await _pumpDiscoveryScreen(
tester,
connectionProvider: connectionProvider,
screen: const DiscoveryScreen(autoDiscoverRepeatersOnOpen: true),
);
expect(connectionProvider.discoveredAdvertTypes, [2]);
expect(find.text('Repeater discovery sent'), findsOneWidget);
});
testWidgets('search and inline type filters narrow discovered nodes', ( testWidgets('search and inline type filters narrow discovered nodes', (
tester, tester,
) async { ) async {

View File

@@ -4,27 +4,16 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
import 'package:meshcore_sar_app/screens/sensors_tab.dart'; import 'package:meshcore_sar_app/screens/sensors_tab.dart';
import 'package:provider/provider.dart'; import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
setUp(() { Contact buildSensorContact({
SharedPreferences.setMockInitialValues({}); int firstByte = 0x44,
}); String name = 'WX Station',
}) {
Future<void> waitUntilLoaded(SensorsProvider provider) async {
for (var i = 0; i < 20 && !provider.isLoaded; i++) {
await Future<void>.delayed(Duration.zero);
}
expect(provider.isLoaded, isTrue);
}
Contact buildSensorContact() {
final publicKey = Uint8List(32); final publicKey = Uint8List(32);
publicKey[0] = 0x44; publicKey[0] = firstByte;
return Contact( return Contact(
publicKey: publicKey, publicKey: publicKey,
@@ -32,7 +21,7 @@ void main() {
flags: 0, flags: 0,
outPathLen: 0, outPathLen: 0,
outPath: Uint8List(64), outPath: Uint8List(64),
advName: 'WX Station', advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0, advLat: 0,
advLon: 0, advLon: 0,
@@ -50,48 +39,84 @@ void main() {
); );
} }
testWidgets('customize action opens full customization view', (tester) async { testWidgets('metric selector item shows channel-specific preview', (
tester,
) async {
final contact = buildSensorContact(); final contact = buildSensorContact();
final sensorsProvider = SensorsProvider(); final option = sensorMetricOptionsFor(contact, labelOverrides: const {})
final contactsProvider = ContactsProvider(); .firstWhere(
(entry) => entry.key.contains('illuminance') && entry.channel == 2,
await waitUntilLoaded(sensorsProvider); );
contactsProvider.addOrUpdateContact(contact);
await sensorsProvider.addSensor(contact);
await tester.pumpWidget( await tester.pumpWidget(
MultiProvider( MaterialApp(
providers: [ localizationsDelegates: AppLocalizations.localizationsDelegates,
ChangeNotifierProvider<ContactsProvider>.value( supportedLocales: AppLocalizations.supportedLocales,
value: contactsProvider, home: Scaffold(
body: SensorMetricSelectorItem(
option: option,
visible: true,
span: 1,
canMoveUp: false,
canMoveDown: false,
onToggle: (_) {},
onRename: () {},
onSpanChanged: (_) {},
), ),
ChangeNotifierProvider<SensorsProvider>.value(value: sensorsProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const SensorsTab(),
), ),
), ),
); );
await tester.pump(); await tester.pump();
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Customize fields'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Customize WX Station'), findsOneWidget);
expect(find.text('Live preview'), findsOneWidget);
expect(find.text('Refresh schedule'), findsOneWidget);
expect( expect(
find.byKey( find.byKey(ValueKey('sensor_selector_metric_${option.key}')),
const ValueKey('sensor_selector_metric_channel_extra:illuminance_2'),
),
findsOneWidget, findsOneWidget,
); );
expect(find.text('Channel 2'), findsOneWidget); expect(find.text('Channel 2'), findsOneWidget);
}); });
testWidgets('add sensor sheet keeps close action available when scrolled', (
tester,
) async {
tester.view.physicalSize = const Size(320, 480);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
var didClose = false;
final candidates = List<Contact>.generate(
20,
(index) =>
buildSensorContact(firstByte: index + 1, name: 'Sensor ${index + 1}'),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: AddSensorSheet(
candidates: candidates,
onSelect: (_) async {},
onClose: () {
didClose = true;
},
),
),
),
);
await tester.pump();
final closeButton = find.byTooltip('Close');
expect(closeButton, findsOneWidget);
await tester.drag(find.byType(ListView).last, const Offset(0, -600));
await tester.pump();
expect(closeButton, findsOneWidget);
await tester.tap(closeButton);
await tester.pump();
expect(didClose, isTrue);
});
} }

View File

@@ -0,0 +1,23 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/services/compass_support.dart';
void main() {
tearDown(() {
debugDefaultTargetPlatformOverride = null;
});
test('reports compass support only on mobile targets', () {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(CompassSupport.isAvailable, isTrue);
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
expect(CompassSupport.isAvailable, isTrue);
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
expect(CompassSupport.isAvailable, isFalse);
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
expect(CompassSupport.isAvailable, isFalse);
});
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/services/location_tracking_service.dart';
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
void main() {
final service = LocationTrackingService();
setUp(() {
SharedPreferences.setMockInitialValues({});
ProfileStorageScope.setScope(
profilesEnabled: false,
activeProfileId: 'default',
);
service.fastLocationUpdatesEnabled = false;
service.fastLocationMovementThresholdMeters = 10.0;
service.fastLocationActiveCadenceSeconds = 10;
service.fastLocationChannelIdx = null;
});
test('loads conservative fast location defaults', () async {
await service.loadSettings();
expect(service.fastLocationMovementThresholdMeters, 10.0);
expect(service.fastLocationActiveCadenceSeconds, 10);
});
test('persists and restores fast location channel idx', () async {
await service.updateFastLocationChannelIdx(3);
service.fastLocationChannelIdx = null;
await service.loadSettings();
expect(service.fastLocationChannelIdx, 3);
});
test('clears persisted fast location channel idx when unset', () async {
await service.updateFastLocationChannelIdx(7);
await service.updateFastLocationChannelIdx(null);
service.fastLocationChannelIdx = 99;
await service.loadSettings();
expect(service.fastLocationChannelIdx, isNull);
});
test('clamps fast location settings to conservative limits', () async {
await service.updateFastLocationMovementThreshold(3);
await service.updateFastLocationActiveCadenceSeconds(45);
expect(service.fastLocationMovementThresholdMeters, 10.0);
expect(service.fastLocationActiveCadenceSeconds, 31);
});
}

View File

@@ -206,4 +206,59 @@ void main() {
expect(history.directPaths.single.source, PathRecordSource.observed); expect(history.directPaths.single.source, PathRecordSource.observed);
}, },
); );
test('last successful direct path is chosen by location fit', () async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 7,
pathBytes: [0xAA],
hopCount: 1,
hashSize: 1,
);
await service.initialize();
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0x11]),
hopCount: 1,
hashSize: 1,
),
success: true,
roundTripTimeMs: 120,
senderLatitude: 46.0,
senderLongitude: 14.0,
recipientLatitude: 46.1,
recipientLongitude: 14.1,
);
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0x22]),
hopCount: 1,
hashSize: 1,
),
success: true,
roundTripTimeMs: 90,
senderLatitude: 46.0001,
senderLongitude: 14.0001,
recipientLatitude: 46.1001,
recipientLongitude: 14.1001,
);
final selection = await service.getLastSuccessfulDirectSelection(
contact,
excludeSignature: 'aa',
senderLatitude: 46.0002,
senderLongitude: 14.0002,
recipientLatitude: 46.1002,
recipientLongitude: 14.1002,
);
expect(selection, isNotNull);
expect(selection!.mode, PathSelectionMode.directHistorical);
expect(selection.canonicalPath, '22');
});
} }

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/widgets/connection_dialog.dart';
import 'package:provider/provider.dart';
class _FakeConnectionProvider extends ConnectionProvider {
int startScanCalls = 0;
int stopScanCalls = 0;
bool _isScanning = false;
@override
bool get isScanning => _isScanning;
@override
List<ScannedDevice> get scannedDevices => const [];
@override
String? get error => null;
@override
Future<void> startScan() async {
startScanCalls += 1;
_isScanning = true;
notifyListeners();
}
@override
Future<void> stopScan() async {
stopScanCalls += 1;
_isScanning = false;
notifyListeners();
}
}
class _ConnectableFakeConnectionProvider extends ConnectionProvider {
int connectCalls = 0;
@override
List<ScannedDevice> get scannedDevices => [
ScannedDevice(device: BluetoothDevice.fromId('test-device'), rssi: -55),
];
@override
String? get error => null;
@override
Future<bool> connect(BluetoothDevice device) async {
connectCalls += 1;
return true;
}
}
void main() {
testWidgets('BLE scan waits for explicit user action', (tester) async {
final connectionProvider = _FakeConnectionProvider();
await tester.pumpWidget(
ChangeNotifierProvider<ConnectionProvider>.value(
value: connectionProvider,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: ConnectionDialog()),
),
),
);
await tester.pump();
expect(connectionProvider.startScanCalls, 0);
expect(
find.text('Press scan to search for nearby devices'),
findsOneWidget,
);
expect(find.text('Scan'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Scan'));
await tester.pump();
expect(connectionProvider.stopScanCalls, 1);
expect(connectionProvider.startScanCalls, 1);
});
testWidgets('successful BLE connect closes the dialog immediately', (
tester,
) async {
final connectionProvider = _ConnectableFakeConnectionProvider();
await tester.pumpWidget(
ChangeNotifierProvider<ConnectionProvider>.value(
value: connectionProvider,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: FilledButton(
onPressed: () {
showModalBottomSheet<Object?>(
context: context,
isScrollControlled: true,
builder: (_) => const ConnectionDialog(),
);
},
child: const Text('Open'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.byType(ConnectionDialog), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
await tester.pumpAndSettle();
expect(connectionProvider.connectCalls, 1);
expect(find.byType(ConnectionDialog), findsNothing);
});
}

View File

@@ -181,6 +181,44 @@ void main() {
} }
}); });
testWidgets('channel bubbles refresh to synced channel names', (
tester,
) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'channel-name-refresh',
messageType: MessageType.channel,
senderPublicKeyPrefix: _prefix(61),
channelIdx: 3,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Team update',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.sent,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
expect(find.text('Channel 3'), findsOneWidget);
expect(find.text('#slovenija'), findsNothing);
harness.channelsProvider.addOrUpdateChannel(
index: 3,
name: '#slovenija',
secret: Uint8List(16),
);
await tester.pumpAndSettle();
expect(find.text('#slovenija'), findsOneWidget);
expect(find.text('Channel 3'), findsNothing);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('message bubble detects and opens links', (tester) async { testWidgets('message bubble detects and opens links', (tester) async {
final harness = await _TestHarness.create(); final harness = await _TestHarness.create();
try { try {

View File

@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/widgets/messages/recipient_selector_sheet.dart'; import 'package:meshcore_sar_app/widgets/messages/recipient_selector_sheet.dart';
void main() { void main() {
@@ -31,13 +33,18 @@ void main() {
); );
} }
Future<void> pumpSheet(WidgetTester tester) async { Future<void> pumpSheet(
final channel = buildContact( WidgetTester tester, {
name: 'Ops', List<Contact>? contacts,
type: ContactType.channel, List<Contact>? channels,
secondByte: 3, MessagesProvider? messagesProvider,
); bool showAllOption = true,
final contact = buildContact(name: 'John Smith', type: ContactType.chat); }) async {
final resolvedChannels =
channels ??
[buildContact(name: 'Ops', type: ContactType.channel, secondByte: 3)];
final resolvedContacts =
contacts ?? [buildContact(name: 'John Smith', type: ContactType.chat)];
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
@@ -45,15 +52,17 @@ void main() {
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold( home: Scaffold(
body: RecipientSelectorSheet( body: RecipientSelectorSheet(
contacts: [contact], contacts: resolvedContacts,
rooms: const [], rooms: const [],
channels: [channel], channels: resolvedChannels,
unreadCount: 11, unreadCount: 11,
unreadCountsByPublicKey: { unreadCountsByPublicKey: {
channel.publicKeyHex: 7, for (final channel in resolvedChannels) channel.publicKeyHex: 7,
contact.publicKeyHex: 3, for (final contact in resolvedContacts) contact.publicKeyHex: 3,
}, },
currentDestinationType: 'all', currentDestinationType: 'all',
showAllOption: showAllOption,
messagesProvider: messagesProvider,
onSelect: (selectedContact, destinationType) {}, onSelect: (selectedContact, destinationType) {},
), ),
), ),
@@ -150,4 +159,88 @@ void main() {
expect(charlieY, lessThan(bravoY)); expect(charlieY, lessThan(bravoY));
expect(bravoY, lessThan(alphaY)); expect(bravoY, lessThan(alphaY));
}); });
testWidgets('shows channel activity and participants instead of raw ids', (
tester,
) async {
final channel = buildContact(
name: 'Ops',
type: ContactType.channel,
secondByte: 3,
);
final messagesProvider = MessagesProvider()
..addMessage(
Message(
id: 'channel-activity',
messageType: MessageType.channel,
senderName: 'Radio Alpha',
channelIdx: 3,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp:
DateTime.now()
.subtract(const Duration(minutes: 5))
.millisecondsSinceEpoch ~/
1000,
text: 'status update',
receivedAt: DateTime.now().subtract(const Duration(minutes: 5)),
),
);
await pumpSheet(
tester,
contacts: const [],
channels: [channel],
messagesProvider: messagesProvider,
showAllOption: false,
);
expect(
find.byKey(Key('channel-participants-${channel.publicKeyHex}')),
findsOneWidget,
);
expect(find.text('Radio Alpha'), findsNothing);
expect(find.text('5m ago'), findsOneWidget);
expect(find.textContaining('Channel 3'), findsNothing);
expect(find.text(channel.publicKeyShort.toUpperCase()), findsNothing);
});
testWidgets('shows contact activity instead of the public key', (
tester,
) async {
final contact = buildContact(
name: 'John Smith',
type: ContactType.chat,
secondByte: 4,
);
final messagesProvider = MessagesProvider()
..addMessage(
Message(
id: 'contact-activity',
messageType: MessageType.contact,
recipientPublicKey: contact.publicKey,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp:
DateTime.now()
.subtract(const Duration(hours: 2))
.millisecondsSinceEpoch ~/
1000,
text: 'check-in',
receivedAt: DateTime.now().subtract(const Duration(hours: 2)),
),
);
await pumpSheet(
tester,
contacts: [contact],
channels: const [],
messagesProvider: messagesProvider,
showAllOption: false,
);
expect(find.text('John Smith'), findsOneWidget);
expect(find.text('2h ago'), findsOneWidget);
expect(find.text(contact.publicKeyShort), findsNothing);
});
} }