Compare commits

...

11 Commits

Author SHA1 Message Date
Janez T
6145462555 chore: Bump iOS build #123 2026-03-19 21:39:51 +01:00
Janez T
721fb7e7a2 feat: Add serial compass support #0 2026-03-19 21:38:54 +01:00
Janez T
5a58e45e13 fix: Add contacts test providers 2026-03-19 14:28:10 +01:00
Janez T
de4258c0a4 fix: Resolve DeviceInfo import clash 2026-03-19 14:18:48 +01:00
Janez T
d8cb35c595 fix: Simplify device settings styling #123 2026-03-19 14:18:29 +01:00
Janez T
8da69705ee fix: Align contact sorting labels #0 2026-03-19 14:06:05 +01:00
Janez T
e474168ba8 feat: Show self telemetry in device info sheet
Tapping signal/battery in header now also shows device's own telemetry
(temperature, humidity, pressure, GPS) when available. Triggers a self
telemetry request on sheet open. Data comes from the device contact's
telemetry field.
2026-03-19 13:52:15 +01:00
Janez T
03dba8b3ce feat: Device info sheet on signal/battery tap
Tapping the signal bars or battery indicator in the header now shows
a bottom sheet with device details: BLE signal (dBm), battery %,
voltage, storage usage, firmware version, frequency, and TX power.

Fixes #22 (partial — device telemetry display)
2026-03-19 11:07:08 +01:00
Janez T
bc9e37bd22 fix: Count RX data and speed up Flutter builds #123 2026-03-19 10:33:20 +01:00
Janez T
a32d5d2c85 fix: Show irradiance values #123 2026-03-19 09:24:53 +01:00
Janez T
e683ab933f fix: Show original poster name in room messages, not room server name
Room servers forward messages with the original author's 4-byte public
key prefix (roomPostAuthorPrefix). Was ignoring this — all room messages
showed the room server's name as sender.

Now resolves the actual poster's name from roomPostAuthorPrefix by
matching against the contacts list. Falls back to room server name if
the author isn't a known contact.

Also fixes findContactByPrefix to accept 4-byte prefixes (was rejecting
anything shorter than 6 bytes).

Fixes #22
2026-03-19 09:20:22 +01:00
52 changed files with 3414 additions and 1227 deletions

View File

@@ -40,6 +40,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable channel: stable
cache: true cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Cache Android NDK - name: Cache Android NDK
uses: actions/cache@v4 uses: actions/cache@v4
@@ -95,6 +98,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable channel: stable
cache: true cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable Linux desktop - name: Enable Linux desktop
run: flutter config --enable-linux-desktop run: flutter config --enable-linux-desktop
@@ -132,6 +138,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable channel: stable
cache: true cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable Windows desktop - name: Enable Windows desktop
shell: pwsh shell: pwsh
@@ -174,6 +183,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable channel: stable
cache: true cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable macOS desktop - name: Enable macOS desktop
run: flutter config --enable-macos-desktop run: flutter config --enable-macos-desktop
@@ -216,6 +228,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable channel: stable
cache: true cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get
@@ -272,6 +287,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable channel: stable
cache: true cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable web - name: Enable web
run: flutter config --enable-web run: flutter config --enable-web

View File

@@ -27,6 +27,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable' channel: 'stable'
cache: true cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get

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 = 119; CURRENT_PROJECT_VERSION = 122;
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 = 119; CURRENT_PROJECT_VERSION = 122;
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 = 119; CURRENT_PROJECT_VERSION = 122;
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 = 119; CURRENT_PROJECT_VERSION = 122;
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 = 119; CURRENT_PROJECT_VERSION = 122;
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 = 119; CURRENT_PROJECT_VERSION = 122;
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>119</string> <string>122</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.00103"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000814">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.372199"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.353323">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="121.394124"> <testcase classname="fastlane.lanes" name="2: build_app" time="115.148883">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="365.414242"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="204.902574">
</testcase> </testcase>

View File

@@ -57,9 +57,7 @@ class Channel {
} else { } else {
// Normal channel: require explicit secret // Normal channel: require explicit secret
if (explicitSecret == null || explicitSecret.length != 16) { if (explicitSecret == null || explicitSecret.length != 16) {
throw ArgumentError( throw ArgumentError('Normal channels require a 16-byte secret');
'Normal channels require a 16-byte secret',
);
} }
return Channel( return Channel(
index: index, index: index,
@@ -78,6 +76,18 @@ class Channel {
return Uint8List.fromList(digest.bytes.sublist(0, 16)); return Uint8List.fromList(digest.bytes.sublist(0, 16));
} }
static bool isHashChannelName(String channelName) {
return channelName.trim().startsWith('#');
}
static String pskBase64ForHashChannelName(String channelName) {
final normalized = channelName.trim();
if (!isHashChannelName(normalized)) {
throw ArgumentError('Only #channels can export derived psk_base64');
}
return base64.encode(_generateHashChannelSecret(normalized));
}
/// Create the default public channel (channel 0) /// Create the default public channel (channel 0)
/// Uses the well-known pre-shared key from MeshCore /// Uses the well-known pre-shared key from MeshCore
factory Channel.publicChannel() { factory Channel.publicChannel() {
@@ -85,8 +95,22 @@ class Channel {
index: 0, index: 0,
name: 'Public Channel', name: 'Public Channel',
secret: Uint8List.fromList([ secret: Uint8List.fromList([
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, 0x8b,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72, 0x33,
0x87,
0xe9,
0xc5,
0xcd,
0xea,
0x6a,
0xc9,
0xe5,
0xed,
0xba,
0xa1,
0x15,
0xcd,
0x72,
]), ]),
flags: null, flags: null,
); );
@@ -95,6 +119,9 @@ class Channel {
/// Check if this is a hash-based channel (name starts with '#') /// Check if this is a hash-based channel (name starts with '#')
bool get isHashChannel => name.startsWith('#'); bool get isHashChannel => name.startsWith('#');
/// Base64-encoded PSK for sharing with firmware CLI and related tooling.
String get pskBase64 => base64.encode(secret);
/// Display name for the channel /// Display name for the channel
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N" /// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
String get displayName { String get displayName {
@@ -131,12 +158,7 @@ class Channel {
} }
/// Create a copy with modified fields /// Create a copy with modified fields
Channel copyWith({ Channel copyWith({int? index, String? name, Uint8List? secret, int? flags}) {
int? index,
String? name,
Uint8List? secret,
int? flags,
}) {
return Channel( return Channel(
index: index ?? this.index, index: index ?? this.index,
name: name ?? this.name, name: name ?? this.name,

View File

@@ -29,7 +29,7 @@ extension ConnectionModeExtension on ConnectionMode {
case ConnectionMode.tcp: case ConnectionMode.tcp:
return 'Direct (WiFi)'; return 'Direct (WiFi)';
case ConnectionMode.usb: case ConnectionMode.usb:
return 'Direct (USB)'; return 'Direct (Serial)';
} }
} }
@@ -40,7 +40,7 @@ extension ConnectionModeExtension on ConnectionMode {
case ConnectionMode.tcp: case ConnectionMode.tcp:
return 'Direct WiFi/TCP connection to MeshCore device'; return 'Direct WiFi/TCP connection to MeshCore device';
case ConnectionMode.usb: case ConnectionMode.usb:
return 'USB serial connection to MeshCore device'; return 'Direct serial connection to MeshCore device';
} }
} }
} }

View File

@@ -107,6 +107,15 @@ class _PendingRepeaterOwnerRequest {
const _PendingRepeaterOwnerRequest({required this.publicKey}); const _PendingRepeaterOwnerRequest({required this.publicKey});
} }
enum ContactsTabSection {
favourites,
teamMembers,
repeaters,
sensors,
rooms,
channels,
}
/// Main App Provider - coordinates all other providers /// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier { class AppProvider with ChangeNotifier {
static const int _maxDirectPayloadHops = 3; static const int _maxDirectPayloadHops = 3;
@@ -162,6 +171,9 @@ class AppProvider with ChangeNotifier {
bool get isContactsEnabled => _isContactsEnabled; bool get isContactsEnabled => _isContactsEnabled;
bool _isSensorsEnabled = true; bool _isSensorsEnabled = true;
bool get isSensorsEnabled => _isSensorsEnabled; bool get isSensorsEnabled => _isSensorsEnabled;
final Map<ContactsTabSection, bool> _contactsSectionVisibility = {
for (final section in ContactsTabSection.values) section: true,
};
bool _isVoiceSilenceTrimmingEnabled = true; bool _isVoiceSilenceTrimmingEnabled = true;
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled; bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
@@ -238,6 +250,7 @@ class AppProvider with ChangeNotifier {
_initializeLocationTracking(); _initializeLocationTracking();
_loadMapEnabled(); _loadMapEnabled();
_loadContactsEnabled(); _loadContactsEnabled();
_loadContactsSectionVisibility();
_loadSensorsEnabled(); _loadSensorsEnabled();
_loadVoiceSilenceTrimmingEnabled(); _loadVoiceSilenceTrimmingEnabled();
_loadVoiceBandPassFilterEnabled(); _loadVoiceBandPassFilterEnabled();
@@ -259,6 +272,27 @@ class AppProvider with ChangeNotifier {
return ProfileStorageScope.scopedKey(baseKey); return ProfileStorageScope.scopedKey(baseKey);
} }
bool isContactsSectionEnabled(ContactsTabSection section) {
return _contactsSectionVisibility[section] ?? true;
}
String _contactsSectionVisibilityKey(ContactsTabSection section) {
switch (section) {
case ContactsTabSection.favourites:
return 'contacts_section_favourites_enabled';
case ContactsTabSection.teamMembers:
return 'contacts_section_team_members_enabled';
case ContactsTabSection.repeaters:
return 'contacts_section_repeaters_enabled';
case ContactsTabSection.sensors:
return 'contacts_section_sensors_enabled';
case ContactsTabSection.rooms:
return 'contacts_section_rooms_enabled';
case ContactsTabSection.channels:
return 'contacts_section_channels_enabled';
}
}
void _startPacketCapturePersistence() { void _startPacketCapturePersistence() {
_packetCaptureFlushTimer?.cancel(); _packetCaptureFlushTimer?.cancel();
_packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) { _packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) {
@@ -565,6 +599,37 @@ class AppProvider with ChangeNotifier {
} }
} }
Future<void> _loadContactsSectionVisibility() async {
try {
final prefs = await SharedPreferences.getInstance();
for (final section in ContactsTabSection.values) {
_contactsSectionVisibility[section] =
prefs.getBool(_scopedKey(_contactsSectionVisibilityKey(section))) ??
true;
}
notifyListeners();
} catch (e) {
debugPrint('Error loading contacts section visibility settings: $e');
}
}
Future<void> setContactsSectionEnabled(
ContactsTabSection section,
bool enabled,
) async {
try {
_contactsSectionVisibility[section] = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
_scopedKey(_contactsSectionVisibilityKey(section)),
enabled,
);
notifyListeners();
} catch (e) {
debugPrint('Error saving contacts section visibility setting: $e');
}
}
/// Load sensors enabled setting from shared preferences /// Load sensors enabled setting from shared preferences
Future<void> _loadSensorsEnabled() async { Future<void> _loadSensorsEnabled() async {
try { try {
@@ -933,9 +998,7 @@ class AppProvider with ChangeNotifier {
// we also import into firmware so subsequent getContact calls work. // we also import into firmware so subsequent getContact calls work.
// Matches the official app which calls cmdGetAdvertPath for all adverts. // Matches the official app which calls cmdGetAdvertPath for all adverts.
if (source == ContactReceiveSource.advert) { if (source == ContactReceiveSource.advert) {
unawaited( unawaited(connectionProvider.importReceivedAdvert(contact.publicKey));
connectionProvider.importReceivedAdvert(contact.publicKey),
);
} }
final isNewPendingAdvert = contactsProvider final isNewPendingAdvert = contactsProvider
.addOrUpdatePendingAdvertContact( .addOrUpdatePendingAdvertContact(
@@ -1134,16 +1197,31 @@ class AppProvider with ChangeNotifier {
return; return;
} }
// Enrich message with sender name from contacts first // Enrich message with sender name from contacts.
// For room-forwarded messages, resolve the original poster's name
// from roomPostAuthorPrefix instead of the room server's key.
Message enrichedMessage = message; Message enrichedMessage = message;
Contact? senderContact; Contact? senderContact;
if (message.senderPublicKeyPrefix != null && message.senderName == null) { if (message.senderName == null) {
final contact = contactsProvider.findContactByKey( // Try room post author first (the actual person who posted)
message.senderPublicKeyPrefix!, if (message.roomPostAuthorPrefix != null) {
); final author = contactsProvider.findContactByPrefix(
if (contact != null) { message.roomPostAuthorPrefix!,
senderContact = contact; );
enrichedMessage = message.copyWith(senderName: contact.advName); if (author != null) {
senderContact = author;
enrichedMessage = message.copyWith(senderName: author.advName);
}
}
// Fall back to sender key (direct messages, or room with unknown author)
if (senderContact == null && message.senderPublicKeyPrefix != null) {
final contact = contactsProvider.findContactByKey(
message.senderPublicKeyPrefix!,
);
if (contact != null) {
senderContact = contact;
enrichedMessage = message.copyWith(senderName: contact.advName);
}
} }
} }
senderContact ??= message.senderPublicKeyPrefix != null senderContact ??= message.senderPublicKeyPrefix != null
@@ -1337,9 +1415,7 @@ class AppProvider with ChangeNotifier {
if (_handlePendingRepeaterOwnerResponse(tag, responseData)) { if (_handlePendingRepeaterOwnerResponse(tag, responseData)) {
return; return;
} }
debugPrint( debugPrint('📊 [AppProvider] Binary response (0x8C tag=$tag) received');
'📊 [AppProvider] Binary response (0x8C tag=$tag) received',
);
// Binary responses carry Cayenne LPP telemetry data. // Binary responses carry Cayenne LPP telemetry data.
// The data starts with a channel byte — valid LPP always has at least // The data starts with a channel byte — valid LPP always has at least
// 3 bytes (channel + type + value). Skip clearly non-telemetry payloads. // 3 bytes (channel + type + value). Skip clearly non-telemetry payloads.
@@ -2144,9 +2220,7 @@ class AppProvider with ChangeNotifier {
// already added to pending adverts and showed notification. // already added to pending adverts and showed notification.
// Nothing else to do — the callback pipeline handles everything. // Nothing else to do — the callback pipeline handles everything.
if (wasKnown) { if (wasKnown) {
debugPrint( debugPrint(' [pushAdvert] Existing contact refreshed');
' [pushAdvert] Existing contact refreshed',
);
} }
} }
@@ -2706,6 +2780,7 @@ class AppProvider with ChangeNotifier {
await Future.wait([ await Future.wait([
_loadMapEnabled(), _loadMapEnabled(),
_loadContactsEnabled(), _loadContactsEnabled(),
_loadContactsSectionVisibility(),
_loadSensorsEnabled(), _loadSensorsEnabled(),
_loadVoiceSilenceTrimmingEnabled(), _loadVoiceSilenceTrimmingEnabled(),
_loadVoiceBandPassFilterEnabled(), _loadVoiceBandPassFilterEnabled(),

View File

@@ -73,6 +73,7 @@ class ConnectionProvider with ChangeNotifier {
final MeshCoreBleService _bleService = MeshCoreBleService(); final MeshCoreBleService _bleService = MeshCoreBleService();
MeshCoreTcpService? _tcpService; MeshCoreTcpService? _tcpService;
MeshCoreSerialService? _serialService; MeshCoreSerialService? _serialService;
Future<void> Function()? _serialDisconnectTransport;
/// Expose BLE service for background location tracking /// Expose BLE service for background location tracking
MeshCoreBleService get bleService => _bleService; MeshCoreBleService get bleService => _bleService;
@@ -246,7 +247,7 @@ class ConnectionProvider with ChangeNotifier {
} }
if (activeMode != ConnectionMode.usb && _serialService != null) { if (activeMode != ConnectionMode.usb && _serialService != null) {
_disposeSerialService(); await _disposeSerialService();
} }
} }
@@ -260,10 +261,20 @@ class ConnectionProvider with ChangeNotifier {
service.dispose(); service.dispose();
} }
void _disposeSerialService() { Future<void> _disposeSerialService() async {
_serialService?.markDisconnected(); final service = _serialService;
_serialService?.dispose(); final disconnectTransport = _serialDisconnectTransport;
_serialService = null; _serialService = null;
_serialDisconnectTransport = null;
if (service == null) {
return;
}
service.markDisconnected();
service.writeRaw = null;
if (disconnectTransport != null) {
await disconnectTransport();
}
service.dispose();
} }
void _beginConnectionAttempt({ void _beginConnectionAttempt({
@@ -635,6 +646,13 @@ class ConnectionProvider with ChangeNotifier {
debugPrint('✅ [Provider] Scan state initialized, notifying listeners'); debugPrint('✅ [Provider] Scan state initialized, notifying listeners');
try { try {
final adapterError = await _awaitBleAdapterReady();
if (adapterError != null) {
debugPrint('⚠️ [Provider] BLE adapter not ready: $adapterError');
_error = adapterError;
return;
}
await for (final scanResult in _bleService.scanForDevices( await for (final scanResult in _bleService.scanForDevices(
timeout: const Duration(seconds: 10), timeout: const Duration(seconds: 10),
)) { )) {
@@ -676,6 +694,53 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<String?> _awaitBleAdapterReady({
Duration timeout = const Duration(seconds: 4),
}) async {
if (!await FlutterBluePlus.isSupported) {
return 'Bluetooth is not supported on this device.';
}
var state = await FlutterBluePlus.adapterState.first;
debugPrint('🔵 [Provider] Initial BLE adapter state: $state');
if (state == BluetoothAdapterState.unknown ||
state == BluetoothAdapterState.turningOn) {
debugPrint('⏳ [Provider] Waiting for BLE adapter to finish initializing');
try {
state = await FlutterBluePlus.adapterState
.where(
(candidate) =>
candidate != BluetoothAdapterState.unknown &&
candidate != BluetoothAdapterState.turningOn,
)
.first
.timeout(timeout);
} on TimeoutException {
state = FlutterBluePlus.adapterStateNow;
}
debugPrint('🔵 [Provider] BLE adapter state after wait: $state');
}
if (state == BluetoothAdapterState.on) {
return null;
}
return switch (state) {
BluetoothAdapterState.off =>
'Bluetooth is turned off. Turn it on and try again.',
BluetoothAdapterState.turningOff =>
'Bluetooth is turning off. Turn it back on and try again.',
BluetoothAdapterState.unauthorized =>
'Bluetooth access is not allowed. Check app permissions in System Settings and try again.',
BluetoothAdapterState.unavailable =>
'Bluetooth hardware is unavailable. On macOS, verify Bluetooth access is enabled for this app target.',
BluetoothAdapterState.unknown || BluetoothAdapterState.turningOn =>
'Bluetooth is still initializing. Please try again in a moment.',
BluetoothAdapterState.on => null,
};
}
/// Stop scanning /// Stop scanning
Future<void> stopScan() async { Future<void> stopScan() async {
await FlutterBluePlus.stopScan(); await FlutterBluePlus.stopScan();
@@ -762,16 +827,22 @@ class ConnectionProvider with ChangeNotifier {
/// The caller is responsible for opening the serial port and wiring /// The caller is responsible for opening the serial port and wiring
/// [service.writeRaw] + [service.feedRawBytes] before calling this. /// [service.writeRaw] + [service.feedRawBytes] before calling this.
/// After this call succeeds, [service.markConnected()] has already run. /// After this call succeeds, [service.markConnected()] has already run.
Future<bool> connectSerial(MeshCoreSerialService service) async { Future<bool> connectSerial({
required MeshCoreSerialService service,
required Future<void> Function() disconnectTransport,
required String deviceId,
required String deviceName,
}) async {
debugPrint('🔌 [Provider] connectSerial()'); debugPrint('🔌 [Provider] connectSerial()');
await _prepareForConnectionSwitch(ConnectionMode.usb); await _prepareForConnectionSwitch(ConnectionMode.usb);
_disposeSerialService(); await _disposeSerialService();
_serialService = service; _serialService = service;
_serialDisconnectTransport = disconnectTransport;
_wireServiceCallbacks(_serialService!); _wireServiceCallbacks(_serialService!);
_beginConnectionAttempt( _beginConnectionAttempt(
mode: ConnectionMode.usb, mode: ConnectionMode.usb,
deviceId: 'usb', deviceId: deviceId,
deviceName: 'USB Companion', deviceName: deviceName,
); );
// markConnected() should already have been called by the transport. // markConnected() should already have been called by the transport.
@@ -780,6 +851,7 @@ class ConnectionProvider with ChangeNotifier {
_deviceInfo = _deviceInfo.copyWith( _deviceInfo = _deviceInfo.copyWith(
connectionState: ConnectionState.error, connectionState: ConnectionState.error,
); );
await _disposeSerialService();
notifyListeners(); notifyListeners();
return false; return false;
} }
@@ -788,7 +860,7 @@ class ConnectionProvider with ChangeNotifier {
/// Disconnect from USB serial device. /// Disconnect from USB serial device.
Future<void> disconnectSerial() async { Future<void> disconnectSerial() async {
_disposeSerialService(); await _disposeSerialService();
_resetConnectionSession(); _resetConnectionSession();
} }
@@ -2363,6 +2435,24 @@ class ConnectionProvider with ChangeNotifier {
/// - Total storage in KB (if available) /// - Total storage in KB (if available)
/// ///
/// Results arrive via onBatteryAndStorage callback and update deviceInfo. /// Results arrive via onBatteryAndStorage callback and update deviceInfo.
/// Request the companion device's own telemetry (GPS, temp, sensors).
/// The response arrives via onTelemetryReceived with the device's own key.
Future<void> requestSelfTelemetry() async {
if (!_activeService.isConnected) return;
try {
// Request own telemetry by sending telemetry req with zero-length key
final deviceKey = _deviceInfo.publicKey;
if (deviceKey != null) {
await _activeService.requestTelemetry(
Uint8List.fromList(deviceKey),
zeroHop: true,
);
}
} catch (e) {
debugPrint('⚠️ [Provider] requestSelfTelemetry failed: $e');
}
}
Future<void> getBatteryAndStorage() async { Future<void> getBatteryAndStorage() async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
@@ -2753,6 +2843,12 @@ class ConnectionProvider with ChangeNotifier {
_rxActivityTimer?.cancel(); _rxActivityTimer?.cancel();
_txActivityTimer?.cancel(); _txActivityTimer?.cancel();
_stopAckCleanupTimer(); _stopAckCleanupTimer();
final disconnectTransport = _serialDisconnectTransport;
if (disconnectTransport != null) {
unawaited(disconnectTransport());
_serialDisconnectTransport = null;
}
_serialService?.dispose();
_bleService.dispose(); _bleService.dispose();
_tcpService?.dispose(); _tcpService?.dispose();
super.dispose(); super.dispose();

View File

@@ -1195,10 +1195,11 @@ class ContactsProvider with ChangeNotifier {
/// Find contact by public key prefix (6 bytes) /// Find contact by public key prefix (6 bytes)
Contact? _findContactByPrefix(Uint8List prefix) { Contact? _findContactByPrefix(Uint8List prefix) {
if (prefix.length < 6) return null; if (prefix.isEmpty) return null;
final takeLen = prefix.length < 6 ? prefix.length : 6;
final prefixHex = prefix final prefixHex = prefix
.sublist(0, 6) .sublist(0, takeLen)
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(''); .join('');

File diff suppressed because it is too large Load Diff

View File

@@ -729,9 +729,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _gpsLoading = false); setState(() => _gpsLoading = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Failed to set GPS mode: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Failed to set GPS mode: $e')));
} }
} }
@@ -893,7 +893,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
.toList(); .toList();
if (contacts.isEmpty) { if (contacts.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.noDeviceContactsToClear)), SnackBar(
content: Text(AppLocalizations.of(context)!.noDeviceContactsToClear),
),
); );
return; return;
} }
@@ -988,7 +990,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
.toList(); .toList();
if (channels.isEmpty) { if (channels.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.noCustomChannelsToClear)), SnackBar(
content: Text(AppLocalizations.of(context)!.noCustomChannelsToClear),
),
); );
return; return;
} }
@@ -1095,19 +1099,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
body: Container( body: ColoredBox(
decoration: BoxDecoration( color: colorScheme.surface,
gradient: LinearGradient(
colors: [
colorScheme.primaryContainer.withValues(alpha: 0.55),
colorScheme.surface,
colorScheme.surface,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.0, 0.22, 1.0],
),
),
child: SafeArea( child: SafeArea(
child: ListView( child: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
@@ -1146,7 +1139,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
SizedBox(height: 20), SizedBox(height: 20),
_ConfigSectionCard( _ConfigSectionCard(
title: AppLocalizations.of(context)!.storage, title: AppLocalizations.of(context)!.storage,
subtitle: AppLocalizations.of(context)!.availableSpaceOnThisDevice, subtitle: AppLocalizations.of(
context,
)!.availableSpaceOnThisDevice,
icon: Icons.storage_rounded, icon: Icons.storage_rounded,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1190,7 +1185,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
icon: _autoAddDiscoveredContactsEnabled icon: _autoAddDiscoveredContactsEnabled
? Icons.person_add_alt_1 ? Icons.person_add_alt_1
: Icons.person_add_disabled, : Icons.person_add_disabled,
title: AppLocalizations.of(context)!.enableAutomaticAdding, title: AppLocalizations.of(
context,
)!.enableAutomaticAdding,
description: description:
'Turn this off to keep discoveries manual-only on the radio.', 'Turn this off to keep discoveries manual-only on the radio.',
accentColor: _autoAddDiscoveredContactsEnabled accentColor: _autoAddDiscoveredContactsEnabled
@@ -1293,7 +1290,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
SizedBox(height: 14), SizedBox(height: 14),
_SettingHighlightCard( _SettingHighlightCard(
icon: Icons.history_toggle_off_rounded, icon: Icons.history_toggle_off_rounded,
title: AppLocalizations.of(context)!.overwriteOldestWhenFull, title: AppLocalizations.of(
context,
)!.overwriteOldestWhenFull,
description: description:
'Allow the radio to replace the oldest contact when storage is full.', 'Allow the radio to replace the oldest contact when storage is full.',
accentColor: _overwriteOldestAutoAddEnabled accentColor: _overwriteOldestAutoAddEnabled
@@ -1329,7 +1328,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
: _saveAutoDiscoverySettings, : _saveAutoDiscoverySettings,
isSaving: _isSavingAutoDiscoverySettings, isSaving: _isSavingAutoDiscoverySettings,
isSaved: _autoDiscoverySettingsSaved, isSaved: _autoDiscoverySettingsSaved,
label: AppLocalizations.of(context)!.saveDiscoverySettings, label: AppLocalizations.of(
context,
)!.saveDiscoverySettings,
), ),
), ),
], ],
@@ -1338,7 +1339,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
SizedBox(height: 20), SizedBox(height: 20),
_ConfigSectionCard( _ConfigSectionCard(
title: AppLocalizations.of(context)!.publicInfo, title: AppLocalizations.of(context)!.publicInfo,
subtitle: AppLocalizations.of(context)!.chooseTheNameAndLocationThisDeviceShares, subtitle: AppLocalizations.of(
context,
)!.chooseTheNameAndLocationThisDeviceShares,
icon: Icons.public_rounded, icon: Icons.public_rounded,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1368,9 +1371,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
if (_gpsEnabled != null) ...[ if (_gpsEnabled != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_SettingHighlightCard( _SettingHighlightCard(
icon: _gpsEnabled! icon: _gpsEnabled! ? Icons.gps_fixed : Icons.gps_off,
? Icons.gps_fixed
: Icons.gps_off,
title: AppLocalizations.of(context)!.gpsModule, title: AppLocalizations.of(context)!.gpsModule,
description: description:
'Enable or disable the onboard GPS hardware.', 'Enable or disable the onboard GPS hardware.',
@@ -1411,13 +1412,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
Container( Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.65), color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
border: Border.all( border: Border.all(color: colorScheme.outlineVariant),
color: colorScheme.outlineVariant.withValues(
alpha: 0.7,
),
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1492,7 +1489,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
SizedBox(height: 20), SizedBox(height: 20),
_ConfigSectionCard( _ConfigSectionCard(
title: AppLocalizations.of(context)!.radioSettings, title: AppLocalizations.of(context)!.radioSettings,
subtitle: AppLocalizations.of(context)!.chooseAPresetOrFinetuneCustomRadioSettings, subtitle: AppLocalizations.of(
context,
)!.chooseAPresetOrFinetuneCustomRadioSettings,
icon: Icons.settings_input_antenna_rounded, icon: Icons.settings_input_antenna_rounded,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1716,7 +1715,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
SizedBox(height: 16), SizedBox(height: 16),
_SettingHighlightCard( _SettingHighlightCard(
icon: Icons.repeat_rounded, icon: Icons.repeat_rounded,
title: AppLocalizations.of(context)!.repeatNearbyTraffic, title: AppLocalizations.of(
context,
)!.repeatNearbyTraffic,
description: description:
deviceInfo.allowedRepeatFreqRanges != null && deviceInfo.allowedRepeatFreqRanges != null &&
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
@@ -1762,7 +1763,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
SizedBox(height: 20), SizedBox(height: 20),
_ConfigSectionCard( _ConfigSectionCard(
title: AppLocalizations.of(context)!.dangerZone, title: AppLocalizations.of(context)!.dangerZone,
subtitle: AppLocalizations.of(context)!.destructiveDeviceActions, subtitle: AppLocalizations.of(
context,
)!.destructiveDeviceActions,
icon: Icons.warning_amber_rounded, icon: Icons.warning_amber_rounded,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1770,13 +1773,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
Container( Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.errorContainer.withValues( color: colorScheme.surfaceContainerLow,
alpha: 0.55,
),
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
border: Border.all( border: Border.all(color: colorScheme.error),
color: colorScheme.error.withValues(alpha: 0.28),
),
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1794,14 +1793,14 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
'Wipe data on device', 'Wipe data on device',
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: colorScheme.onErrorContainer, color: colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Erase contacts, keys, and radio settings from the connected MeshCore device and return it to factory defaults.', 'Erase contacts, keys, and radio settings from the connected MeshCore device and return it to factory defaults.',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onErrorContainer, color: colorScheme.onSurfaceVariant,
), ),
), ),
], ],
@@ -1831,7 +1830,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
), ),
) )
: Icon(Icons.people_alt_outlined), : Icon(Icons.people_alt_outlined),
label: Text(AppLocalizations.of(context)!.clearAllContacts), label: Text(
AppLocalizations.of(context)!.clearAllContacts,
),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -1855,7 +1856,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
), ),
) )
: Icon(Icons.forum_outlined), : Icon(Icons.forum_outlined),
label: Text(AppLocalizations.of(context)!.clearAllChannels), label: Text(
AppLocalizations.of(context)!.clearAllChannels,
),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -1871,7 +1874,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
minimumSize: const Size.fromHeight(52), minimumSize: const Size.fromHeight(52),
), ),
icon: Icon(Icons.delete_forever_rounded), icon: Icon(Icons.delete_forever_rounded),
label: Text(AppLocalizations.of(context)!.wipeDeviceData), label: Text(
AppLocalizations.of(context)!.wipeDeviceData,
),
), ),
), ),
], ],
@@ -1930,26 +1935,9 @@ class _ConfigHeroCard extends StatelessWidget {
return Container( return Container(
padding: const EdgeInsets.all(22), padding: const EdgeInsets.all(22),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: colorScheme.surfaceContainerLow,
colors: [
colorScheme.primaryContainer,
colorScheme.primary.withValues(alpha: 0.14),
colorScheme.surfaceContainerHighest,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
border: Border.all( border: Border.all(color: colorScheme.outlineVariant),
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.08),
),
boxShadow: [
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.10),
blurRadius: 26,
offset: const Offset(0, 12),
),
],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1960,13 +1948,10 @@ class _ConfigHeroCard extends StatelessWidget {
width: 56, width: 56,
height: 56, height: 56,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.12), color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
), ),
child: Icon( child: Icon(Icons.tune_rounded, color: colorScheme.primary),
Icons.tune_rounded,
color: colorScheme.onPrimaryContainer,
),
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
@@ -1979,15 +1964,13 @@ class _ConfigHeroCard extends StatelessWidget {
vertical: 6, vertical: 6,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.onPrimaryContainer.withValues( color: colorScheme.surfaceContainerHighest,
alpha: 0.10,
),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
), ),
child: Text( child: Text(
'Device settings', 'Device settings',
style: theme.textTheme.labelMedium?.copyWith( style: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onPrimaryContainer, color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
@@ -1997,16 +1980,14 @@ class _ConfigHeroCard extends StatelessWidget {
title, title,
style: theme.textTheme.headlineSmall?.copyWith( style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: colorScheme.onPrimaryContainer, color: colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
subtitle, subtitle,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onPrimaryContainer.withValues( color: colorScheme.onSurfaceVariant,
alpha: 0.82,
),
height: 1.35, height: 1.35,
), ),
), ),
@@ -2050,23 +2031,24 @@ class _HeroStat extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final background = data.emphasized final iconColor = data.emphasized
? colorScheme.primary.withValues(alpha: 0.18) ? colorScheme.primary
: colorScheme.onPrimaryContainer.withValues(alpha: 0.10); : colorScheme.onSurfaceVariant;
final borderColor = data.emphasized
? colorScheme.primary
: colorScheme.outlineVariant;
return Container( return Container(
constraints: const BoxConstraints(minWidth: 140), constraints: const BoxConstraints(minWidth: 140),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: background, color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(color: borderColor),
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.08),
),
), ),
child: Row( child: Row(
children: [ children: [
Icon(data.icon, size: 18, color: colorScheme.onPrimaryContainer), Icon(data.icon, size: 18, color: iconColor),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
@@ -2075,9 +2057,7 @@ class _HeroStat extends StatelessWidget {
Text( Text(
data.label, data.label,
style: theme.textTheme.labelMedium?.copyWith( style: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onPrimaryContainer.withValues( color: colorScheme.onSurfaceVariant,
alpha: 0.76,
),
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
@@ -2086,7 +2066,7 @@ class _HeroStat extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
color: colorScheme.onPrimaryContainer, color: colorScheme.onSurface,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
), ),
@@ -2118,7 +2098,9 @@ class _ConfigSectionCard extends StatelessWidget {
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return Card( return Card(
color: colorScheme.surfaceContainerLow,
elevation: 0, elevation: 0,
surfaceTintColor: Colors.transparent,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
child: Padding( child: Padding(
@@ -2133,7 +2115,7 @@ class _ConfigSectionCard extends StatelessWidget {
width: 42, width: 42,
height: 42, height: 42,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.10), color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
child: Icon(icon, color: colorScheme.primary), child: Icon(icon, color: colorScheme.primary),
@@ -2259,14 +2241,7 @@ class _StorageUsageMeter extends StatelessWidget {
return Container( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: colorScheme.surfaceContainerLow,
colors: [
colorScheme.surfaceContainerHighest.withValues(alpha: 0.65),
colorScheme.surfaceContainer,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
border: Border.all(color: colorScheme.outlineVariant), border: Border.all(color: colorScheme.outlineVariant),
), ),
@@ -2321,9 +2296,9 @@ class _SelectedPresetCard extends StatelessWidget {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.55), color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: colorScheme.primary.withValues(alpha: 0.18)), border: Border.all(color: colorScheme.outlineVariant),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -2418,16 +2393,9 @@ class _SettingHighlightCard extends StatelessWidget {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: colorScheme.surfaceContainerLow,
colors: [
accentColor.withValues(alpha: 0.14),
colorScheme.surfaceContainerHigh.withValues(alpha: 0.85),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
border: Border.all(color: accentColor.withValues(alpha: 0.18)), border: Border.all(color: colorScheme.outlineVariant),
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -2436,7 +2404,7 @@ class _SettingHighlightCard extends StatelessWidget {
width: 42, width: 42,
height: 42, height: 42,
decoration: BoxDecoration( decoration: BoxDecoration(
color: accentColor.withValues(alpha: 0.16), color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
child: Icon(icon, color: accentColor), child: Icon(icon, color: accentColor),

View File

@@ -8,7 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:vibration/vibration.dart'; import 'package:vibration/vibration.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../models/device_info.dart' show ConnectionMode; import '../models/contact.dart';
import '../models/device_info.dart' show ConnectionMode, DeviceInfo;
import '../providers/messages_provider.dart'; import '../providers/messages_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
@@ -434,6 +435,167 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
void _showDeviceInfoSheet(BuildContext context, DeviceInfo deviceInfo) {
// Request self telemetry so it's fresh
context.read<ConnectionProvider>().requestSelfTelemetry();
// Find the device's own contact to show self telemetry
final selfKey = deviceInfo.publicKey;
Contact? selfContact;
if (selfKey != null) {
selfContact = context.read<ContactsProvider>().findContactByKey(
Uint8List.fromList(selfKey),
);
}
final telemetry = selfContact?.telemetry;
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
Text(
deviceInfo.selfName ?? deviceInfo.deviceName ?? 'Device',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
_deviceInfoRow(
context,
Icons.bluetooth,
'BLE Signal',
deviceInfo.signalRssi != null
? '${deviceInfo.signalRssi} dBm'
: 'N/A',
),
if (deviceInfo.batteryPercent != null)
_deviceInfoRow(
context,
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
'Battery',
'${deviceInfo.batteryPercent!.round()}%',
),
if (deviceInfo.batteryMilliVolts != null)
_deviceInfoRow(
context,
Icons.bolt,
'Voltage',
'${(deviceInfo.batteryMilliVolts! / 1000).toStringAsFixed(2)}V',
),
if (deviceInfo.storageUsedKb != null &&
deviceInfo.storageTotalKb != null)
_deviceInfoRow(
context,
Icons.storage,
'Storage',
'${deviceInfo.storageUsedKb} / ${deviceInfo.storageTotalKb} KB',
),
if (deviceInfo.firmwareVersion != null)
_deviceInfoRow(
context,
Icons.system_update,
'Firmware',
'v${deviceInfo.firmwareVersion}',
),
if (deviceInfo.radioFreq != null)
_deviceInfoRow(
context,
Icons.radio,
'Frequency',
'${(deviceInfo.radioFreq! / 1000).toStringAsFixed(3)} MHz',
),
if (deviceInfo.txPower != null)
_deviceInfoRow(
context,
Icons.power,
'TX Power',
'${deviceInfo.txPower} dBm',
),
if (telemetry != null) ...[
const Divider(height: 24),
Text(
'Self Telemetry',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
if (telemetry.temperature != null)
_deviceInfoRow(
context,
Icons.thermostat,
'Temperature',
'${telemetry.temperature!.toStringAsFixed(1)}°C',
),
if (telemetry.humidity != null)
_deviceInfoRow(
context,
Icons.water_drop,
'Humidity',
'${telemetry.humidity!.toStringAsFixed(1)}%',
),
if (telemetry.pressure != null)
_deviceInfoRow(
context,
Icons.compress,
'Pressure',
'${telemetry.pressure!.toStringAsFixed(1)} hPa',
),
if (telemetry.gpsLocation != null)
_deviceInfoRow(
context,
Icons.gps_fixed,
'GPS',
'${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}',
),
],
],
),
),
),
);
}
Widget _deviceInfoRow(
BuildContext context,
IconData icon,
String label,
String value,
) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Text(label, style: Theme.of(context).textTheme.bodyMedium),
),
Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
);
}
Widget _buildMiniSignalBars({required int activeBars, required Color color}) { Widget _buildMiniSignalBars({required int activeBars, required Color color}) {
final inactive = Colors.grey.withValues(alpha: 0.3); final inactive = Colors.grey.withValues(alpha: 0.3);
return Row( return Row(
@@ -1061,7 +1223,12 @@ class _HomeScreenState extends State<HomeScreen>
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Row( GestureDetector(
onTap: () => _showDeviceInfoSheet(
context,
deviceInfo,
),
child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
@@ -1109,6 +1276,7 @@ class _HomeScreenState extends State<HomeScreen>
], ],
], ],
), ),
),
], ],
), ),
), ),

View File

@@ -27,7 +27,6 @@ T? _maybeProvider<T>(BuildContext context) {
class LiveTrafficScreen extends StatefulWidget { class LiveTrafficScreen extends StatefulWidget {
final List<BlePacketLog> Function() logReader; final List<BlePacketLog> Function() logReader;
final int Function()? rxCountReader;
final Listenable? refreshListenable; final Listenable? refreshListenable;
final DateTime Function() now; final DateTime Function() now;
final VoidCallback? openPacketLogs; final VoidCallback? openPacketLogs;
@@ -35,7 +34,6 @@ class LiveTrafficScreen extends StatefulWidget {
const LiveTrafficScreen({ const LiveTrafficScreen({
super.key, super.key,
required this.logReader, required this.logReader,
this.rxCountReader,
this.refreshListenable, this.refreshListenable,
DateTime Function()? now, DateTime Function()? now,
this.openPacketLogs, this.openPacketLogs,
@@ -49,7 +47,6 @@ class LiveTrafficScreen extends StatefulWidget {
return LiveTrafficScreen( return LiveTrafficScreen(
key: key, key: key,
logReader: () => provider.bleService.packetLogs, logReader: () => provider.bleService.packetLogs,
rxCountReader: () => provider.rxPacketCount,
refreshListenable: provider, refreshListenable: provider,
openPacketLogs: openPacketLogs, openPacketLogs: openPacketLogs,
); );
@@ -118,15 +115,17 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final logs = widget.logReader();
final totalRxDataCount = LiveTrafficSummary.countRxDataLogs(logs);
final unfilteredSnapshot = LiveTrafficSummary.fromLogs( final unfilteredSnapshot = LiveTrafficSummary.fromLogs(
widget.logReader(), logs,
now: widget.now(), now: widget.now(),
clearedAt: _clearedAt, clearedAt: _clearedAt,
preferredHashSize: _preferredHashSize, preferredHashSize: _preferredHashSize,
window: _selectedWindow, window: _selectedWindow,
); );
final snapshot = LiveTrafficSummary.fromLogs( final snapshot = LiveTrafficSummary.fromLogs(
widget.logReader(), logs,
now: widget.now(), now: widget.now(),
clearedAt: _clearedAt, clearedAt: _clearedAt,
preferredHashSize: _preferredHashSize, preferredHashSize: _preferredHashSize,
@@ -177,7 +176,7 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
children: [ children: [
_SummaryPanel( _SummaryPanel(
snapshot: snapshot, snapshot: snapshot,
totalRxCount: widget.rxCountReader?.call(), totalRxCount: totalRxDataCount,
routeHashCounts: routeHashCounts, routeHashCounts: routeHashCounts,
onWindowTap: () => _showWindowPicker(context), onWindowTap: () => _showWindowPicker(context),
), ),
@@ -306,8 +305,48 @@ class _SummaryPanel extends StatelessWidget {
LiveTrafficBusyness.active => 'Active', LiveTrafficBusyness.active => 'Active',
LiveTrafficBusyness.busy => 'Busy', LiveTrafficBusyness.busy => 'Busy',
}; };
final metrics = [
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:rxPackets'),
label: AppLocalizations.of(context)!.rxPackets,
value: '${snapshot.rxCount}',
subtitle: totalRxCount == null
? _windowSummaryLabel(snapshot.windowDuration)
: 'Device total $totalRxCount',
),
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:rssi'),
label: AppLocalizations.of(context)!.rssi,
value: snapshot.latestRssiDbm == null
? 'No RX data'
: '${snapshot.latestRssiDbm} dBm',
subtitle: snapshot.avgRssiDbm == null
? 'No average yet'
: 'Avg ${snapshot.avgRssiDbm!.toStringAsFixed(1)} dBm',
),
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:snr'),
label: AppLocalizations.of(context)!.snr,
value: snapshot.latestSnrDb == null
? 'No RX data'
: '${snapshot.latestSnrDb!.toStringAsFixed(1)} dB',
subtitle: snapshot.avgSnrDb == null
? 'No average yet'
: 'Avg ${snapshot.avgSnrDb!.toStringAsFixed(1)} dB',
),
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:multihop'),
label: AppLocalizations.of(context)!.multihop,
value: '${snapshot.multiHopCount}',
subtitle: routeHashCounts.summaryLabel,
footer: snapshot.avgHopCount == null
? 'No routes yet'
: 'Avg ${snapshot.avgHopCount!.toStringAsFixed(1)} hops',
),
];
return Container( return Container(
width: double.infinity,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
@@ -346,44 +385,26 @@ class _SummaryPanel extends StatelessWidget {
], ],
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
Wrap( LayoutBuilder(
spacing: 10, builder: (context, constraints) {
runSpacing: 10, const spacing = 10.0;
children: [ const minTileWidth = 150.0;
_MetricTile( final columns =
label: AppLocalizations.of(context)!.rxPackets, ((constraints.maxWidth + spacing) / (minTileWidth + spacing))
value: '${snapshot.rxCount}', .floor()
subtitle: totalRxCount == null .clamp(1, metrics.length);
? _windowSummaryLabel(snapshot.windowDuration) final tileWidth =
: 'Device total $totalRxCount', (constraints.maxWidth - (spacing * (columns - 1))) / columns;
),
_MetricTile( return Wrap(
label: AppLocalizations.of(context)!.rssi, spacing: spacing,
value: snapshot.latestRssiDbm == null runSpacing: spacing,
? 'No RX data' children: [
: '${snapshot.latestRssiDbm} dBm', for (final metric in metrics)
subtitle: snapshot.avgRssiDbm == null SizedBox(width: tileWidth, child: metric),
? 'No average yet' ],
: 'Avg ${snapshot.avgRssiDbm!.toStringAsFixed(1)} dBm', );
), },
_MetricTile(
label: AppLocalizations.of(context)!.snr,
value: snapshot.latestSnrDb == null
? 'No RX data'
: '${snapshot.latestSnrDb!.toStringAsFixed(1)} dB',
subtitle: snapshot.avgSnrDb == null
? 'No average yet'
: 'Avg ${snapshot.avgSnrDb!.toStringAsFixed(1)} dB',
),
_MetricTile(
label: AppLocalizations.of(context)!.multihop,
value: '${snapshot.multiHopCount}',
subtitle: routeHashCounts.summaryLabel,
footer: snapshot.avgHopCount == null
? 'No routes yet'
: 'Avg ${snapshot.avgHopCount!.toStringAsFixed(1)} hops',
),
],
), ),
], ],
), ),
@@ -525,12 +546,14 @@ String _windowSummaryLabel(Duration duration) {
} }
class _MetricTile extends StatelessWidget { class _MetricTile extends StatelessWidget {
final Key? tileKey;
final String label; final String label;
final String value; final String value;
final String subtitle; final String subtitle;
final String? footer; final String? footer;
const _MetricTile({ const _MetricTile({
this.tileKey,
required this.label, required this.label,
required this.value, required this.value,
required this.subtitle, required this.subtitle,
@@ -540,7 +563,7 @@ class _MetricTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: 160, key: tileKey,
height: 136, height: 136,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -949,7 +972,6 @@ class _LiveTrafficCard extends StatelessWidget {
), ),
); );
} }
} }
class _LiveTrafficPacketDetails { class _LiveTrafficPacketDetails {
@@ -1105,7 +1127,6 @@ class _PacketInfoLine extends StatelessWidget {
} }
} }
class _GeoPoint { class _GeoPoint {
final double latitude; final double latitude;
final double longitude; final double longitude;

View File

@@ -28,6 +28,7 @@ import '../services/voice_recorder_service.dart';
import '../services/voice_codec_service.dart'; import '../services/voice_codec_service.dart';
import '../utils/toast_logger.dart'; import '../utils/toast_logger.dart';
import '../utils/key_comparison.dart'; import '../utils/key_comparison.dart';
import '../utils/contact_sorting.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
import '../utils/tictactoe_message_parser.dart'; import '../utils/tictactoe_message_parser.dart';
@@ -299,27 +300,27 @@ class _MessagesTabState extends State<MessagesTab> {
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final normalizedQuery = query.trim().toLowerCase(); final normalizedQuery = query.trim().toLowerCase();
final contacts = final contacts =
contactsProvider.contacts contactsProvider.chatContacts.where((contact) {
.where((contact) => contact.type == ContactType.chat) if (normalizedQuery.isEmpty) return true;
.where((contact) { return contact.displayName.toLowerCase().contains(normalizedQuery);
if (normalizedQuery.isEmpty) return true; }).toList()..sort((a, b) {
return contact.displayName.toLowerCase().contains( final primary = compareContactsByFavouriteThenLastSeen(a, b);
normalizedQuery, if (primary != 0) {
); return primary;
}) }
.toList()
..sort((a, b) { final aName = a.displayName.toLowerCase();
final aName = a.displayName.toLowerCase(); final bName = b.displayName.toLowerCase();
final bName = b.displayName.toLowerCase(); final aStarts =
final aStarts = normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery);
normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery); final bStarts =
final bStarts = normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery);
normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery); if (aStarts != bStarts) {
if (aStarts != bStarts) { return aStarts ? -1 : 1;
return aStarts ? -1 : 1; }
}
return aName.compareTo(bName); return compareContactsByDisplayName(a, b);
}); });
return contacts.take(8).toList(growable: false); return contacts.take(8).toList(growable: false);
} }
@@ -408,16 +409,33 @@ class _MessagesTabState extends State<MessagesTab> {
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
// Filter contacts by type final contacts = List<Contact>.from(contactsProvider.chatContacts)
final contacts = contactsProvider.contacts ..sort((a, b) {
.where((c) => c.type == ContactType.chat) final primary = compareContactsByFavouriteThenLastSeen(a, b);
.toList(); if (primary != 0) {
final rooms = contactsProvider.contacts return primary;
.where((c) => c.type == ContactType.room) }
.toList();
final channels = contactsProvider.contacts return compareContactsByDisplayName(a, b);
.where((c) => c.type == ContactType.channel) });
.toList(); final rooms = List<Contact>.from(contactsProvider.rooms)
..sort((a, b) {
final primary = compareContactsByLastSeen(a, b);
if (primary != 0) {
return primary;
}
return compareContactsByDisplayName(a, b);
});
final channels = List<Contact>.from(contactsProvider.channels)
..sort((a, b) {
final primary = compareContactsByLastSeen(a, b);
if (primary != 0) {
return primary;
}
return compareContactsByDisplayName(a, b);
});
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -534,7 +552,10 @@ class _MessagesTabState extends State<MessagesTab> {
}).firstOrNull; }).firstOrNull;
if (recipient == null) { if (recipient == null) {
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplyContactNotFound); ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotReplyContactNotFound,
);
return; return;
} }
} }
@@ -563,13 +584,19 @@ class _MessagesTabState extends State<MessagesTab> {
} else { } else {
final senderPrefix = message.senderPublicKeyPrefix; final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix == null || senderPrefix.length < 6) { if (senderPrefix == null || senderPrefix.length < 6) {
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplySenderMissing); ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotReplySenderMissing,
);
return; return;
} }
recipient = contactsProvider.findContactByPrefix(senderPrefix); recipient = contactsProvider.findContactByPrefix(senderPrefix);
if (recipient == null) { if (recipient == null) {
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplyContactNotFound); ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotReplyContactNotFound,
);
return; return;
} }

View File

@@ -190,9 +190,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
_isDeveloperModeEnabled = false; _isDeveloperModeEnabled = false;
_versionTapCount = 0; _versionTapCount = 0;
}); });
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.developerModeDisabled))); content: Text(AppLocalizations.of(context)!.developerModeDisabled),
),
);
return; return;
} }
@@ -204,9 +206,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
_isDeveloperModeEnabled = true; _isDeveloperModeEnabled = true;
_versionTapCount = 0; _versionTapCount = 0;
}); });
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.developerModeEnabled))); content: Text(AppLocalizations.of(context)!.developerModeEnabled),
),
);
return; return;
} }
@@ -559,7 +563,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid), content: Text(
AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid,
),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
), ),
); );
@@ -584,7 +590,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (!updateInfo.isAvailable) { if (!updateInfo.isAvailable) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context)!.youAreRunningTheLatestVersion), content: Text(
AppLocalizations.of(context)!.youAreRunningTheLatestVersion,
),
backgroundColor: Colors.green, backgroundColor: Colors.green,
), ),
); );
@@ -594,7 +602,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (updateInfo.downloadUrl == null) { if (updateInfo.downloadUrl == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context)!.updateAvailableButDownloadUrlNotFound), content: Text(
AppLocalizations.of(
context,
)!.updateAvailableButDownloadUrlNotFound,
),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
), ),
); );
@@ -1178,6 +1190,100 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
]), ]),
_buildSectionHeader(AppLocalizations.of(context)!.contacts),
Consumer<AppProvider>(
builder: (context, appProvider, child) => _buildSettingsCard([
SwitchListTile(
secondary: const Icon(Icons.star_outline_rounded),
title: Text(AppLocalizations.of(context)!.favourites),
subtitle: const Text(
'Show the favourites section in the contacts tab',
),
value: appProvider.isContactsSectionEnabled(
ContactsTabSection.favourites,
),
onChanged: (value) async {
await appProvider.setContactsSectionEnabled(
ContactsTabSection.favourites,
value,
);
},
),
SwitchListTile(
secondary: const Icon(Icons.people_alt_outlined),
title: Text(AppLocalizations.of(context)!.teamMembers),
subtitle: const Text(
'Show direct team contacts in the contacts tab',
),
value: appProvider.isContactsSectionEnabled(
ContactsTabSection.teamMembers,
),
onChanged: (value) async {
await appProvider.setContactsSectionEnabled(
ContactsTabSection.teamMembers,
value,
);
},
),
SwitchListTile(
secondary: const Icon(Icons.router_outlined),
title: Text(AppLocalizations.of(context)!.repeaters),
subtitle: const Text('Show repeater nodes in the contacts tab'),
value: appProvider.isContactsSectionEnabled(
ContactsTabSection.repeaters,
),
onChanged: (value) async {
await appProvider.setContactsSectionEnabled(
ContactsTabSection.repeaters,
value,
);
},
),
SwitchListTile(
secondary: const Icon(Icons.sensors_outlined),
title: Text(AppLocalizations.of(context)!.sensors),
subtitle: const Text('Show sensor nodes in the contacts tab'),
value: appProvider.isContactsSectionEnabled(
ContactsTabSection.sensors,
),
onChanged: (value) async {
await appProvider.setContactsSectionEnabled(
ContactsTabSection.sensors,
value,
);
},
),
SwitchListTile(
secondary: const Icon(Icons.meeting_room_outlined),
title: Text(AppLocalizations.of(context)!.rooms),
subtitle: const Text('Show rooms in the contacts tab'),
value: appProvider.isContactsSectionEnabled(
ContactsTabSection.rooms,
),
onChanged: (value) async {
await appProvider.setContactsSectionEnabled(
ContactsTabSection.rooms,
value,
);
},
),
SwitchListTile(
secondary: const Icon(Icons.broadcast_on_personal_outlined),
title: Text(AppLocalizations.of(context)!.channels),
subtitle: const Text('Show channels in the contacts tab'),
value: appProvider.isContactsSectionEnabled(
ContactsTabSection.channels,
),
onChanged: (value) async {
await appProvider.setContactsSectionEnabled(
ContactsTabSection.channels,
value,
);
},
),
]),
),
_buildSectionHeader('Messaging'), _buildSectionHeader('Messaging'),
_buildSettingsCard([ _buildSettingsCard([
ListTile( ListTile(
@@ -1205,7 +1311,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.route), secondary: Icon(Icons.route),
title: Text(AppLocalizations.of(context)!.nearestRepeaterFallback), title: Text(
AppLocalizations.of(context)!.nearestRepeaterFallback,
),
subtitle: const Text( subtitle: const Text(
'After normal retries fail, try one final resend through the nearest repeater', 'After normal retries fail, try one final resend through the nearest repeater',
), ),
@@ -1234,7 +1342,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
'Clear Messages', 'Clear Messages',
style: TextStyle(color: Colors.red), style: TextStyle(color: Colors.red),
), ),
subtitle: Text(AppLocalizations.of(context)!.deleteAllStoredMessageHistory), subtitle: Text(
AppLocalizations.of(context)!.deleteAllStoredMessageHistory,
),
onTap: _clearMessages, onTap: _clearMessages,
), ),
Consumer<AppProvider>( Consumer<AppProvider>(
@@ -1344,7 +1454,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, drawingProvider, child) => SwitchListTile( builder: (context, drawingProvider, child) => SwitchListTile(
secondary: Icon(Icons.fmd_good_outlined), secondary: Icon(Icons.fmd_good_outlined),
title: Text(AppLocalizations.of(context)!.showSarMarkersLabel), title: Text(AppLocalizations.of(context)!.showSarMarkersLabel),
subtitle: Text(AppLocalizations.of(context)!.displaySarMarkersOnTheMainMap), subtitle: Text(
AppLocalizations.of(context)!.displaySarMarkersOnTheMainMap,
),
value: drawingProvider.showSarMarkers, value: drawingProvider.showSarMarkers,
onChanged: (value) { onChanged: (value) {
drawingProvider.toggleSarMarkers(); drawingProvider.toggleSarMarkers();
@@ -1354,7 +1466,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<MapProvider>( Consumer<MapProvider>(
builder: (context, mapProvider, child) => SwitchListTile( builder: (context, mapProvider, child) => SwitchListTile(
secondary: Icon(Icons.timeline), secondary: Icon(Icons.timeline),
title: Text(AppLocalizations.of(context)!.showAllContactTrailsLabel), title: Text(
AppLocalizations.of(context)!.showAllContactTrailsLabel,
),
subtitle: const Text( subtitle: const Text(
'Display location trails for all contacts that have history', 'Display location trails for all contacts that have history',
), ),
@@ -1420,7 +1534,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.compress), secondary: Icon(Icons.compress),
title: Text(AppLocalizations.of(context)!.voiceCompressor), title: Text(AppLocalizations.of(context)!.voiceCompressor),
subtitle: Text(AppLocalizations.of(context)!.balancesQuietAndLoudSpeechLevels), subtitle: Text(
AppLocalizations.of(
context,
)!.balancesQuietAndLoudSpeechLevels,
),
value: appProvider.isVoiceCompressorEnabled, value: appProvider.isVoiceCompressorEnabled,
onChanged: (value) async { onChanged: (value) async {
await appProvider.toggleVoiceCompressorEnabled(value); await appProvider.toggleVoiceCompressorEnabled(value);
@@ -1431,7 +1549,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.speed), secondary: Icon(Icons.speed),
title: Text(AppLocalizations.of(context)!.voiceLimiter), title: Text(AppLocalizations.of(context)!.voiceLimiter),
subtitle: Text(AppLocalizations.of(context)!.preventsClippingPeaksBeforeEncoding), subtitle: Text(
AppLocalizations.of(
context,
)!.preventsClippingPeaksBeforeEncoding,
),
value: appProvider.isVoiceLimiterEnabled, value: appProvider.isVoiceLimiterEnabled,
onChanged: (value) async { onChanged: (value) async {
await appProvider.toggleVoiceLimiterEnabled(value); await appProvider.toggleVoiceLimiterEnabled(value);
@@ -1442,7 +1564,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.auto_fix_high), secondary: Icon(Icons.auto_fix_high),
title: Text(AppLocalizations.of(context)!.micAutoGain), title: Text(AppLocalizations.of(context)!.micAutoGain),
subtitle: Text(AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel), subtitle: Text(
AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel,
),
value: appProvider.isVoiceAutoGainEnabled, value: appProvider.isVoiceAutoGainEnabled,
onChanged: (value) async { onChanged: (value) async {
await appProvider.toggleVoiceAutoGainEnabled(value); await appProvider.toggleVoiceAutoGainEnabled(value);
@@ -1478,7 +1602,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.content_cut), secondary: Icon(Icons.content_cut),
title: Text(AppLocalizations.of(context)!.trimSilenceInVoiceMessages), title: Text(
AppLocalizations.of(context)!.trimSilenceInVoiceMessages,
),
subtitle: const Text( subtitle: const Text(
'Removes long silent parts before sending voice', 'Removes long silent parts before sending voice',
), ),
@@ -1648,7 +1774,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
ListTile( ListTile(
leading: Icon(Icons.timer), leading: Icon(Icons.timer),
title: Text(AppLocalizations.of(context)!.activeuseUpdateInterval), title: Text(
AppLocalizations.of(context)!.activeuseUpdateInterval,
),
subtitle: Text('$_fastLocationActiveCadenceSeconds s'), subtitle: Text('$_fastLocationActiveCadenceSeconds s'),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationActiveCadence, onTap: _editFastLocationActiveCadence,

View File

@@ -141,6 +141,18 @@ class LiveTrafficSummary {
const LiveTrafficSummary._(); const LiveTrafficSummary._();
static bool isRxDataLog(BlePacketLog log) {
return log.direction == PacketDirection.rx &&
log.responseCode == logRxDataResponseCode;
}
static int countRxDataLogs(Iterable<BlePacketLog> logs, {DateTime? since}) {
return logs.where((log) {
return isRxDataLog(log) &&
(since == null || !log.timestamp.isBefore(since));
}).length;
}
static LiveTrafficSnapshot fromLogs( static LiveTrafficSnapshot fromLogs(
Iterable<BlePacketLog> logs, { Iterable<BlePacketLog> logs, {
required DateTime now, required DateTime now,
@@ -154,15 +166,14 @@ class LiveTrafficSummary {
? clearedAt ? clearedAt
: windowStart; : windowStart;
final recentLogs = logs final recentLogs =
.where( logs
(log) => .where(
log.direction == PacketDirection.rx && (log) =>
log.responseCode == logRxDataResponseCode && isRxDataLog(log) && !log.timestamp.isBefore(effectiveStart),
!log.timestamp.isBefore(effectiveStart), )
) .toList()
.toList() ..sort((a, b) => a.timestamp.compareTo(b.timestamp));
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final entries = <LiveTrafficEntry>[]; final entries = <LiveTrafficEntry>[];
for (final log in recentLogs) { for (final log in recentLogs) {
@@ -215,7 +226,9 @@ class LiveTrafficSummary {
} }
} }
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList(); final visibleEntries = filteredEntries.reversed
.take(maxVisibleEntries)
.toList();
const txCount = 0; const txCount = 0;
final totalCount = rxCount; final totalCount = rxCount;
final packetsPerMinute = final packetsPerMinute =
@@ -233,7 +246,9 @@ class LiveTrafficSummary {
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount, avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
latestRssiDbm: latestRssiDbm, latestRssiDbm: latestRssiDbm,
multiHopCount: multiHopCount, multiHopCount: multiHopCount,
avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples, avgHopCount: hopCountSamples == 0
? null
: hopCountTotal / hopCountSamples,
visibleEntries: visibleEntries, visibleEntries: visibleEntries,
busyness: _busynessForPacketsPerMinute(packetsPerMinute), busyness: _busynessForPacketsPerMinute(packetsPerMinute),
); );

View File

@@ -0,0 +1,50 @@
import 'package:meshcore_client/meshcore_client.dart';
import 'serial_transport_stub.dart'
if (dart.library.io) 'serial_transport_io.dart'
if (dart.library.js_interop) 'serial_transport_web.dart';
const int kSerialBaudRate = 115200;
class SerialDeviceInfo {
final String id;
final String title;
final String subtitle;
final Object handle;
const SerialDeviceInfo({
required this.id,
required this.title,
required this.subtitle,
required this.handle,
});
}
class SerialConnection {
final MeshCoreSerialService service;
final String deviceId;
final String deviceName;
final Future<void> Function() disconnect;
const SerialConnection({
required this.service,
required this.deviceId,
required this.deviceName,
required this.disconnect,
});
}
abstract class SerialTransport {
bool get isSupported;
bool get canRequestDevice;
String get actionLabel;
String get emptyStateTitle;
String get unsupportedMessage;
Future<List<SerialDeviceInfo>> listDevices();
Future<SerialDeviceInfo?> requestDevice();
Future<SerialConnection> connect(SerialDeviceInfo device);
void dispose();
}
SerialTransport createSerialTransport() => createSerialTransportImpl();

View File

@@ -0,0 +1,283 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_libserialport/flutter_libserialport.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:usb_serial/usb_serial.dart';
import 'serial_transport.dart';
class _IoSerialTransport implements SerialTransport {
@override
bool get isSupported =>
Platform.isAndroid || Platform.isMacOS || Platform.isWindows;
@override
bool get canRequestDevice => false;
@override
String get actionLabel =>
Platform.isAndroid ? 'Scan USB devices' : 'Scan serial ports';
@override
String get emptyStateTitle => Platform.isAndroid
? 'No USB serial devices found.\nConnect a MeshCore device via OTG cable.'
: 'No serial ports found.\nConnect a MeshCore device over USB serial.';
@override
String get unsupportedMessage =>
'Serial is supported on Android, macOS, Windows, and compatible web browsers.';
@override
Future<List<SerialDeviceInfo>> listDevices() async {
if (Platform.isAndroid) {
final devices = await UsbSerial.listDevices();
return devices.map(_androidDeviceInfo).toList(growable: false);
}
if (Platform.isMacOS || Platform.isWindows) {
return _desktopSerialDevices();
}
return const [];
}
@override
Future<SerialDeviceInfo?> requestDevice() async => null;
@override
Future<SerialConnection> connect(SerialDeviceInfo device) async {
if (Platform.isAndroid) {
return _connectAndroid(device);
}
if (Platform.isMacOS || Platform.isWindows) {
return _connectDesktop(device);
}
throw UnsupportedError(unsupportedMessage);
}
SerialDeviceInfo _androidDeviceInfo(UsbDevice device) {
final title = _firstNonEmpty([
device.productName,
device.manufacturerName,
'USB Serial Device',
]);
final subtitleParts = <String>[
if ((device.manufacturerName ?? '').trim().isNotEmpty)
device.manufacturerName!.trim(),
if (device.vid != null && device.pid != null)
'VID:${_hex(device.vid!)} PID:${_hex(device.pid!)}',
];
return SerialDeviceInfo(
id: '${device.deviceId}:${device.vid ?? 'na'}:${device.pid ?? 'na'}:${device.productName ?? 'usb'}',
title: title,
subtitle: subtitleParts.isEmpty
? 'Ready over OTG serial'
: subtitleParts.join(''),
handle: device,
);
}
Future<List<SerialDeviceInfo>> _desktopSerialDevices() async {
final ports = <SerialDeviceInfo>[];
for (final address in SerialPort.availablePorts) {
final port = SerialPort(address);
try {
final title = _firstNonEmpty([
port.productName,
port.description,
address,
]);
final subtitleParts = <String>[
address,
if ((port.manufacturer ?? '').trim().isNotEmpty)
port.manufacturer!.trim(),
if (port.vendorId != null && port.productId != null)
'VID:${_hex(port.vendorId!)} PID:${_hex(port.productId!)}',
];
ports.add(
SerialDeviceInfo(
id: address,
title: title,
subtitle: subtitleParts.join(''),
handle: address,
),
);
} finally {
port.dispose();
}
}
return ports;
}
Future<SerialConnection> _connectAndroid(SerialDeviceInfo device) async {
final usbDevice = device.handle as UsbDevice;
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
StreamSubscription<Uint8List>? inputSubscription;
UsbPort? port;
try {
port = await usbDevice.create();
if (port == null) {
throw Exception('Failed to create USB port');
}
final opened = await port.open();
if (!opened) {
throw Exception('Failed to open USB port');
}
await port.setDTR(true);
await port.setRTS(true);
await port.setPortParameters(
kSerialBaudRate,
UsbPort.DATABITS_8,
UsbPort.STOPBITS_1,
UsbPort.PARITY_NONE,
);
service.writeRaw = (data) async {
await port!.write(data);
};
inputSubscription = port.inputStream?.listen(
(data) => service.feedRawBytes(data),
onError: (error) {
debugPrint('❌ [Serial/Android] Read error: $error');
service.markDisconnected();
},
onDone: () {
debugPrint('⚠️ [Serial/Android] Port closed');
service.markDisconnected();
},
);
final connected = await service.markConnected();
if (!connected) {
throw Exception('Serial session initialization failed');
}
return SerialConnection(
service: service,
deviceId:
'${usbDevice.deviceId}:${usbDevice.vid ?? 'na'}:${usbDevice.pid ?? 'na'}',
deviceName: device.title,
disconnect: () async {
await inputSubscription?.cancel();
inputSubscription = null;
service.writeRaw = null;
await port?.close();
port = null;
},
);
} catch (_) {
await inputSubscription?.cancel();
service.writeRaw = null;
await port?.close();
service.dispose();
rethrow;
}
}
Future<SerialConnection> _connectDesktop(SerialDeviceInfo device) async {
final portName = device.handle as String;
final port = SerialPort(portName);
final config = SerialPortConfig();
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
SerialPortReader? reader;
StreamSubscription<Uint8List>? inputSubscription;
try {
if (!port.openReadWrite()) {
throw Exception(
SerialPort.lastError?.message ?? 'Failed to open serial port',
);
}
config.baudRate = kSerialBaudRate;
config.bits = 8;
config.stopBits = 1;
config.parity = SerialPortParity.none;
config.setFlowControl(SerialPortFlowControl.none);
config.dtr = SerialPortDtr.on;
config.rts = SerialPortRts.on;
port.config = config;
service.writeRaw = (data) async {
var offset = 0;
while (offset < data.length) {
final written = port.write(data.sublist(offset));
if (written <= 0) {
throw Exception(
SerialPort.lastError?.message ?? 'Failed to write to serial port',
);
}
offset += written;
}
};
reader = SerialPortReader(port);
inputSubscription = reader.stream.listen(
(data) => service.feedRawBytes(data),
onError: (error) {
debugPrint('❌ [Serial/Desktop] Read error: $error');
service.markDisconnected();
},
onDone: () {
debugPrint('⚠️ [Serial/Desktop] Reader closed');
service.markDisconnected();
},
);
final connected = await service.markConnected();
if (!connected) {
throw Exception('Serial session initialization failed');
}
return SerialConnection(
service: service,
deviceId: portName,
deviceName: device.title,
disconnect: () async {
await inputSubscription?.cancel();
inputSubscription = null;
reader?.close();
reader = null;
service.writeRaw = null;
if (port.isOpen) {
port.close();
}
port.dispose();
config.dispose();
},
);
} catch (_) {
await inputSubscription?.cancel();
reader?.close();
service.writeRaw = null;
if (port.isOpen) {
port.close();
}
port.dispose();
config.dispose();
service.dispose();
rethrow;
}
}
@override
void dispose() {}
}
String _firstNonEmpty(List<String?> values) {
for (final value in values) {
final trimmed = value?.trim();
if (trimmed != null && trimmed.isNotEmpty) {
return trimmed;
}
}
return 'Serial Device';
}
String _hex(int value) => value.toRadixString(16).padLeft(4, '0');
SerialTransport createSerialTransportImpl() => _IoSerialTransport();

View File

@@ -0,0 +1,35 @@
import 'serial_transport.dart';
class _UnsupportedSerialTransport implements SerialTransport {
@override
bool get isSupported => false;
@override
bool get canRequestDevice => false;
@override
String get actionLabel => 'Serial unsupported';
@override
String get emptyStateTitle => 'Serial is unavailable on this platform.';
@override
String get unsupportedMessage =>
'Serial is supported on Android, macOS, Windows, and compatible web browsers.';
@override
Future<List<SerialDeviceInfo>> listDevices() async => const [];
@override
Future<SerialDeviceInfo?> requestDevice() async => null;
@override
Future<SerialConnection> connect(SerialDeviceInfo device) async {
throw UnsupportedError(unsupportedMessage);
}
@override
void dispose() {}
}
SerialTransport createSerialTransportImpl() => _UnsupportedSerialTransport();

View File

@@ -0,0 +1,209 @@
import 'dart:async';
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:web/web.dart' as web;
import 'package:webserial/webserial.dart';
import 'serial_transport.dart';
class _WebSerialTransport implements SerialTransport {
@override
bool get isSupported =>
web.window.navigator.hasProperty('serial'.toJS).toDart;
@override
bool get canRequestDevice => true;
@override
String get actionLabel => 'Choose serial device';
@override
String get emptyStateTitle =>
'No serial devices granted yet.\nChoose a device and approve access in your browser.';
@override
String get unsupportedMessage =>
'Web Serial requires a compatible browser such as Chrome or Edge and a secure context (HTTPS or localhost).';
@override
Future<List<SerialDeviceInfo>> listDevices() async {
if (!isSupported) {
return const [];
}
final ports = await serial.getPorts().toDart;
return ports
.toDart
.whereType<JSSerialPort>()
.map(_deviceInfoForPort)
.toList(growable: false);
}
@override
Future<SerialDeviceInfo?> requestDevice() async {
if (!isSupported) {
return null;
}
final port = await requestWebSerialPort(null);
if (port == null) {
return null;
}
return _deviceInfoForPort(port);
}
@override
Future<SerialConnection> connect(SerialDeviceInfo device) async {
final port = device.handle as JSSerialPort;
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
web.ReadableStreamDefaultReader? reader;
bool keepReading = false;
try {
await port
.open(
JSSerialOptions(
baudRate: kSerialBaudRate,
dataBits: 8,
stopBits: 1,
parity: 'none',
bufferSize: 1024,
flowControl: 'none',
),
)
.toDart;
await port
.setSignals(
JSSerialOutputSignals(
dataTerminalReady: true,
requestToSend: true,
),
)
.toDart;
service.writeRaw = (data) async {
final writable = port.writable;
if (writable == null) {
throw Exception('Serial port is not writable');
}
final writer =
writable.getWriter() as web.WritableStreamDefaultWriter?;
if (writer == null) {
throw Exception('Failed to acquire serial writer');
}
try {
await writer.write(data.toJS).toDart;
} finally {
writer.releaseLock();
}
};
final readable = port.readable;
if (readable == null) {
throw Exception('Serial port is not readable');
}
reader = readable.getReader() as web.ReadableStreamDefaultReader?;
if (reader == null) {
throw Exception('Failed to acquire serial reader');
}
keepReading = true;
unawaited(_readLoop(reader, service, () => keepReading));
final connected = await service.markConnected();
if (!connected) {
throw Exception('Serial session initialization failed');
}
return SerialConnection(
service: service,
deviceId: device.id,
deviceName: device.title,
disconnect: () async {
keepReading = false;
try {
await reader?.cancel().toDart;
} catch (_) {}
try {
reader?.releaseLock();
} catch (_) {}
reader = null;
service.writeRaw = null;
try {
await port.close().toDart;
} catch (_) {}
},
);
} catch (_) {
keepReading = false;
try {
await reader?.cancel().toDart;
} catch (_) {}
try {
reader?.releaseLock();
} catch (_) {}
service.writeRaw = null;
try {
await port.close().toDart;
} catch (_) {}
service.dispose();
rethrow;
}
}
Future<void> _readLoop(
web.ReadableStreamDefaultReader reader,
MeshCoreSerialService service,
bool Function() shouldContinue,
) async {
while (shouldContinue()) {
try {
final result = await reader.read().toDart;
if (result.done) {
break;
}
final value = result.value;
if (value == null) {
continue;
}
service.feedRawBytes((value as JSUint8Array).toDart);
} catch (_) {
break;
}
}
service.markDisconnected();
try {
reader.releaseLock();
} catch (_) {}
}
SerialDeviceInfo _deviceInfoForPort(JSSerialPort port) {
final info = port.getInfo();
final vendorId = info.usbVendorId;
final productId = info.usbProductId;
final title = (vendorId != 0 || productId != 0)
? 'USB ${_hex(vendorId)}:${_hex(productId)}'
: 'Granted serial device';
final subtitle = [
if (vendorId != 0 || productId != 0)
'VID:${_hex(vendorId)} PID:${_hex(productId)}',
'Web Serial',
].join('');
return SerialDeviceInfo(
id: '$title::$subtitle',
title: title,
subtitle: subtitle,
handle: port,
);
}
@override
void dispose() {}
}
String _hex(int value) => value.toRadixString(16).padLeft(4, '0');
SerialTransport createSerialTransportImpl() => _WebSerialTransport();

View File

@@ -0,0 +1,24 @@
import '../models/contact.dart';
int compareContactsByLastSeen(Contact a, Contact b) {
return b.lastSeenTime.compareTo(a.lastSeenTime);
}
int compareContactsByFavouriteThenLastSeen(Contact a, Contact b) {
if (a.isFavourite != b.isFavourite) {
return a.isFavourite ? -1 : 1;
}
return compareContactsByLastSeen(a, b);
}
int compareContactsByDisplayName(Contact a, Contact b) {
final nameCompare = a.displayName.toLowerCase().compareTo(
b.displayName.toLowerCase(),
);
if (nameCompare != 0) {
return nameCompare;
}
return a.publicKeyHex.compareTo(b.publicKeyHex);
}

View File

@@ -1,13 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart' hide Contact;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:usb_serial/usb_serial.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 '../services/network_scanner_service.dart'; import '../services/network_scanner_service.dart';
import '../services/serial/serial_transport.dart';
/// 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 {
@@ -166,7 +164,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'Choose Bluetooth, WiFi, or USB transport', 'Choose Bluetooth, WiFi, or Serial transport',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
@@ -192,7 +190,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
tabs: const [ tabs: const [
Tab(text: 'BLE', icon: Icon(Icons.bluetooth_rounded)), Tab(text: 'BLE', icon: Icon(Icons.bluetooth_rounded)),
Tab(text: 'Network', icon: Icon(Icons.wifi_rounded)), Tab(text: 'Network', icon: Icon(Icons.wifi_rounded)),
Tab(text: 'USB', icon: Icon(Icons.usb_rounded)), Tab(text: 'Serial', icon: Icon(Icons.usb_rounded)),
], ],
), ),
], ],
@@ -250,6 +248,36 @@ class _ConnectionDialogState extends State<ConnectionDialog>
); );
} }
Widget _buildErrorBanner(String message) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.error_outline_rounded,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
],
),
);
}
Widget _buildEmptyState({ Widget _buildEmptyState({
required IconData icon, required IconData icon,
required String title, required String title,
@@ -371,6 +399,8 @@ class _ConnectionDialogState extends State<ConnectionDialog>
message: AppLocalizations.of(context)!.defaultPinInfo, message: AppLocalizations.of(context)!.defaultPinInfo,
onRefresh: _refreshBleDevices, onRefresh: _refreshBleDevices,
), ),
if (connectionProvider.error != null)
_buildErrorBanner(connectionProvider.error!),
Expanded( Expanded(
child: child:
connectionProvider.isScanning && connectionProvider.isScanning &&
@@ -597,7 +627,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
Widget _buildUsbTab() { Widget _buildUsbTab() {
return _UsbDeviceList( return _SerialDeviceList(
buildTransportCard: buildTransportCard:
({ ({
required icon, required icon,
@@ -654,38 +684,48 @@ typedef _EmptyStateBuilder =
required VoidCallback onAction, required VoidCallback onAction,
}); });
class _UsbDeviceList extends StatefulWidget { class _SerialDeviceList extends StatefulWidget {
final VoidCallback onConnected; final VoidCallback onConnected;
final _TransportCardBuilder buildTransportCard; final _TransportCardBuilder buildTransportCard;
final _EmptyStateBuilder buildEmptyState; final _EmptyStateBuilder buildEmptyState;
const _UsbDeviceList({ const _SerialDeviceList({
required this.onConnected, required this.onConnected,
required this.buildTransportCard, required this.buildTransportCard,
required this.buildEmptyState, required this.buildEmptyState,
}); });
@override @override
State<_UsbDeviceList> createState() => _UsbDeviceListState(); State<_SerialDeviceList> createState() => _SerialDeviceListState();
} }
class _UsbDeviceListState extends State<_UsbDeviceList> { class _SerialDeviceListState extends State<_SerialDeviceList> {
List<UsbDevice> _devices = []; final SerialTransport _transport = createSerialTransport();
List<SerialDeviceInfo> _devices = [];
bool _isScanning = false; bool _isScanning = false;
bool _isConnecting = false; bool _isConnecting = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (defaultTargetPlatform == TargetPlatform.android) { if (_transport.isSupported) {
_scanDevices(); _scanDevices();
} }
} }
@override
void dispose() {
_transport.dispose();
super.dispose();
}
Future<void> _scanDevices() async { Future<void> _scanDevices() async {
if (!_transport.isSupported) {
return;
}
setState(() => _isScanning = true); setState(() => _isScanning = true);
try { try {
final devices = await UsbSerial.listDevices(); final devices = await _transport.listDevices();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_devices = devices; _devices = devices;
@@ -700,81 +740,66 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
} }
} }
Future<void> _connectToDevice(UsbDevice device) async { Future<void> _requestDevice() async {
if (!_transport.canRequestDevice) {
await _scanDevices();
return;
}
setState(() => _isScanning = true);
try {
final selectedDevice = await _transport.requestDevice();
final devices = await _transport.listDevices();
if (!mounted) return;
setState(() {
_devices = selectedDevice == null
? devices
: _mergeDevices(devices, selectedDevice);
_isScanning = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_isScanning = false;
});
}
}
List<SerialDeviceInfo> _mergeDevices(
List<SerialDeviceInfo> devices,
SerialDeviceInfo selectedDevice,
) {
final merged = [...devices];
if (!merged.any((device) => device.id == selectedDevice.id)) {
merged.insert(0, selectedDevice);
}
return merged;
}
Future<void> _connectToDevice(SerialDeviceInfo device) async {
setState(() => _isConnecting = true); setState(() => _isConnecting = true);
try { try {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final service = MeshCoreSerialService(appName: 'MeshCore SAR'); final appProvider = context.read<AppProvider>();
final connection = await _transport.connect(device);
final port = await device.create(); final success = await connectionProvider.connectSerial(
if (port == null) { service: connection.service,
if (mounted) { disconnectTransport: connection.disconnect,
setState(() => _isConnecting = false); deviceId: connection.deviceId,
ScaffoldMessenger.of(context).showSnackBar( deviceName: connection.deviceName,
const SnackBar(content: Text('Failed to create USB port')),
);
}
return;
}
final opened = await port.open();
if (!opened) {
if (mounted) {
setState(() => _isConnecting = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to open USB port')),
);
}
return;
}
await port.setDTR(true);
await port.setRTS(true);
await port.setPortParameters(
115200,
UsbPort.DATABITS_8,
UsbPort.STOPBITS_1,
UsbPort.PARITY_NONE,
); );
service.writeRaw = (data) async {
await port.write(data);
};
port.inputStream?.listen(
(data) => service.feedRawBytes(data),
onError: (_) {
service.markDisconnected();
port.close();
},
onDone: () {
service.markDisconnected();
},
);
final sessionOk = await service.markConnected();
if (!sessionOk) {
await port.close();
if (mounted) {
setState(() => _isConnecting = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('USB session initialization failed')),
);
}
return;
}
final success = await connectionProvider.connectSerial(service);
if (!mounted) return; if (!mounted) return;
if (success) { if (success) {
await appProvider.initialize();
widget.onConnected(); widget.onConnected();
} else { } else {
await port.close(); await connection.disconnect();
connection.service.dispose();
if (!mounted) return; if (!mounted) return;
setState(() => _isConnecting = false); setState(() => _isConnecting = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to connect via USB')), const SnackBar(content: Text('Failed to connect via serial')),
); );
} }
} catch (e) { } catch (e) {
@@ -782,20 +807,18 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
setState(() => _isConnecting = false); setState(() => _isConnecting = false);
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text('USB error: $e'))); ).showSnackBar(SnackBar(content: Text('Serial error: $e')));
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (defaultTargetPlatform != TargetPlatform.android) { if (!_transport.isSupported) {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Text( child: Text(
kIsWeb _transport.unsupportedMessage,
? 'Web Serial is not yet supported.\nUse BLE or Network instead.'
: 'USB serial is available on Android only.\nConnect via OTG cable.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
@@ -811,19 +834,22 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: FilledButton.tonalIcon( child: FilledButton.tonalIcon(
onPressed: _isConnecting ? null : _scanDevices, onPressed: _isConnecting
? null
: (_transport.canRequestDevice ? _requestDevice : _scanDevices),
icon: const Icon(Icons.usb_rounded), icon: const Icon(Icons.usb_rounded),
label: const Text('Scan USB devices'), label: Text(_transport.actionLabel),
), ),
), ),
if (_devices.isEmpty) if (_devices.isEmpty)
Expanded( Expanded(
child: widget.buildEmptyState( child: widget.buildEmptyState(
icon: Icons.usb_off_rounded, icon: Icons.usb_off_rounded,
title: title: _transport.emptyStateTitle,
'No USB serial devices found.\nConnect a MeshCore device via OTG cable.', actionLabel: _transport.actionLabel,
actionLabel: 'Scan USB devices', onAction: _transport.canRequestDevice
onAction: _scanDevices, ? _requestDevice
: _scanDevices,
), ),
) )
else else
@@ -835,10 +861,8 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
return widget.buildTransportCard( return widget.buildTransportCard(
icon: Icons.usb_rounded, icon: Icons.usb_rounded,
iconColor: Theme.of(context).colorScheme.primary, iconColor: Theme.of(context).colorScheme.primary,
title: device.productName ?? 'USB Device', title: device.title,
subtitle: (device.manufacturerName?.isNotEmpty ?? false) subtitle: device.subtitle,
? device.manufacturerName!
: 'Ready over OTG serial',
trailing: _isConnecting trailing: _isConnecting
? const SizedBox( ? const SizedBox(
width: 24, width: 24,

View File

@@ -54,6 +54,14 @@ class ContactTile extends StatelessWidget {
return l10n.daysAgo(diff.inDays); return l10n.daysAgo(diff.inDays);
} }
String _threeBytePrefix() {
final bytes = contact.publicKey.take(3);
return bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isChannel = contact.type == ContactType.channel; final isChannel = contact.type == ContactType.channel;
@@ -177,14 +185,7 @@ class ContactTile extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
gradient: LinearGradient( color: colorScheme.surfaceContainerLow,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.surfaceContainerLow,
colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
],
),
border: Border.all( border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.35), color: colorScheme.outlineVariant.withValues(alpha: 0.35),
), ),
@@ -205,46 +206,73 @@ class ContactTile extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Stack( SizedBox(
clipBehavior: Clip.none, width: 58,
children: [ child: Column(
ContactAvatar(contact: contact, radius: 24), mainAxisSize: MainAxisSize.min,
if (contact.isNew) children: [
Positioned( Stack(
top: -2, clipBehavior: Clip.none,
right: -2, children: [
child: Container( ContactAvatar(contact: contact, radius: 24),
width: 14, if (contact.isNew)
height: 14, Positioned(
decoration: BoxDecoration( top: -2,
color: Colors.blue, right: -2,
shape: BoxShape.circle, child: Container(
border: Border.all(color: Colors.white, width: 2), width: 14,
), height: 14,
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2,
),
),
),
),
if (contact.type == ContactType.room &&
roomLoginState != null)
Positioned(
bottom: -2,
right: -2,
child: Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: _getRoomStatusColor(roomLoginState),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2,
),
),
child: Icon(
_getRoomStatusIcon(roomLoginState),
size: 11,
color: Colors.white,
),
),
),
],
),
const SizedBox(height: 8),
Text(
_threeBytePrefix(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
), ),
), ),
if (contact.type == ContactType.room && ],
roomLoginState != null) ),
Positioned(
bottom: -2,
right: -2,
child: Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: _getRoomStatusColor(roomLoginState),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Icon(
_getRoomStatusIcon(roomLoginState),
size: 11,
color: Colors.white,
),
),
),
],
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(

View File

@@ -106,7 +106,10 @@ class SpectrumScanPanel extends StatelessWidget {
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
label: AppLocalizations.of(context)!.quiet, label: AppLocalizations.of(context)!.quiet,
), ),
_LegendChip(color: Colors.orange, label: AppLocalizations.of(context)!.moderate), _LegendChip(
color: Colors.orange,
label: AppLocalizations.of(context)!.moderate,
),
_LegendChip( _LegendChip(
color: theme.colorScheme.error, color: theme.colorScheme.error,
label: AppLocalizations.of(context)!.busy, label: AppLocalizations.of(context)!.busy,
@@ -147,7 +150,7 @@ class SpectrumScanPanel extends StatelessWidget {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(alpha: 0.7), color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.outlineVariant), border: Border.all(color: theme.colorScheme.outlineVariant),
), ),
@@ -303,12 +306,7 @@ class _FrequencyRangePreview extends StatelessWidget {
height: 108, height: 108,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
gradient: LinearGradient( color: scheme.surfaceContainerLow,
colors: [
scheme.surface,
scheme.surfaceContainerHighest.withValues(alpha: 0.9),
],
),
border: Border.all(color: scheme.outlineVariant), border: Border.all(color: scheme.outlineVariant),
), ),
child: Stack( child: Stack(
@@ -322,13 +320,7 @@ class _FrequencyRangePreview extends StatelessWidget {
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
gradient: LinearGradient( color: scheme.surfaceContainerHigh,
colors: [
scheme.primary.withValues(alpha: 0.12),
scheme.tertiary.withValues(alpha: 0.08),
scheme.primary.withValues(alpha: 0.12),
],
),
), ),
), ),
), ),
@@ -341,7 +333,7 @@ class _FrequencyRangePreview extends StatelessWidget {
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
color: scheme.primary.withValues(alpha: 0.20), color: scheme.primaryContainer,
border: Border.all(color: scheme.primary), border: Border.all(color: scheme.primary),
), ),
), ),
@@ -355,7 +347,7 @@ class _FrequencyRangePreview extends StatelessWidget {
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
color: scheme.tertiary.withValues(alpha: 0.32), color: scheme.tertiaryContainer,
border: Border.all(color: scheme.tertiary), border: Border.all(color: scheme.tertiary),
), ),
), ),

View File

@@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../models/contact.dart'; import '../../../models/contact.dart';
import '../../../models/sar_marker.dart'; import '../../../models/sar_marker.dart';
import 'compass_math.dart';
/// Header component for the compass dialog showing compass rose, /// Header component for the compass dialog showing compass rose,
/// heading, elevation, accuracy, and current location in multiple formats. /// heading, elevation, accuracy, and current location in multiple formats.
@@ -76,7 +77,11 @@ class CompassHeader extends StatelessWidget {
); );
} }
Widget _buildInfoRow(BuildContext context, double? heading, Position? position) { Widget _buildInfoRow(
BuildContext context,
double? heading,
Position? position,
) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
@@ -90,17 +95,13 @@ class CompassHeader extends StatelessWidget {
_buildInfoCard( _buildInfoCard(
context, context,
l10n.elevation, l10n.elevation,
position?.altitude != null position?.altitude != null ? '${position!.altitude.round()}m' : '--',
? '${position!.altitude.round()}m'
: '--',
Icons.terrain, Icons.terrain,
), ),
_buildInfoCard( _buildInfoCard(
context, context,
l10n.accuracy, l10n.accuracy,
position?.accuracy != null position?.accuracy != null ? '±${position!.accuracy.round()}m' : '--',
? '±${position!.accuracy.round()}m'
: '--',
Icons.gps_fixed, Icons.gps_fixed,
), ),
], ],
@@ -108,21 +109,22 @@ class CompassHeader extends StatelessWidget {
} }
Widget _buildInfoCard( Widget _buildInfoCard(
BuildContext context, String label, String value, IconData icon) { BuildContext context,
String label,
String value,
IconData icon,
) {
return Column( return Column(
children: [ children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
value, value,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
), ),
Text(label, style: Theme.of(context).textTheme.bodySmall),
], ],
); );
} }
@@ -193,11 +195,16 @@ class _LargeCompassPainter extends CustomPainter {
// Draw degree markers // Draw degree markers
for (int i = 0; i < 360; i += 10) { for (int i = 0; i < 360; i += 10) {
final angle = i * pi / 180 - pi / 2 + heading * pi / 180; final angle = compassDialAngleRadians(
markerDegrees: i.toDouble(),
headingDegrees: heading,
);
final isCardinal = i % 90 == 0; final isCardinal = i % 90 == 0;
final isMajor = i % 30 == 0; final isMajor = i % 30 == 0;
final startRadius = isCardinal ? radius - 25 : (isMajor ? radius - 15 : radius - 10); final startRadius = isCardinal
? radius - 25
: (isMajor ? radius - 15 : radius - 10);
final start = Offset( final start = Offset(
center.dx + startRadius * cos(angle), center.dx + startRadius * cos(angle),
center.dy + startRadius * sin(angle), center.dy + startRadius * sin(angle),
@@ -218,7 +225,10 @@ class _LargeCompassPainter extends CustomPainter {
final textPainter = TextPainter(textDirection: TextDirection.ltr); final textPainter = TextPainter(textDirection: TextDirection.ltr);
final directions = ['N', 'E', 'S', 'W']; final directions = ['N', 'E', 'S', 'W'];
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
final angle = i * pi / 2 - pi / 2 + heading * pi / 180; final angle = compassDialAngleRadians(
markerDegrees: (i * 90).toDouble(),
headingDegrees: heading,
);
final x = center.dx + (radius - 35) * cos(angle); final x = center.dx + (radius - 35) * cos(angle);
final y = center.dy + (radius - 35) * sin(angle); final y = center.dy + (radius - 35) * sin(angle);
@@ -243,20 +253,25 @@ class _LargeCompassPainter extends CustomPainter {
final contactsWithDistance = contacts final contactsWithDistance = contacts
.where((c) => c.displayLocation != null) .where((c) => c.displayLocation != null)
.map((contact) { .map((contact) {
final bearing = _calculateBearing( final bearing = _calculateBearing(
currentPosition!.latitude, currentPosition!.latitude,
currentPosition!.longitude, currentPosition!.longitude,
contact.displayLocation!.latitude, contact.displayLocation!.latitude,
contact.displayLocation!.longitude, contact.displayLocation!.longitude,
); );
final distance = _calculateDistance( final distance = _calculateDistance(
currentPosition!.latitude, currentPosition!.latitude,
currentPosition!.longitude, currentPosition!.longitude,
contact.displayLocation!.latitude, contact.displayLocation!.latitude,
contact.displayLocation!.longitude, contact.displayLocation!.longitude,
); );
return {'contact': contact, 'bearing': bearing, 'distance': distance}; return {
}).toList(); 'contact': contact,
'bearing': bearing,
'distance': distance,
};
})
.toList();
if (contactsWithDistance.isEmpty) return; if (contactsWithDistance.isEmpty) return;
@@ -277,7 +292,8 @@ class _LargeCompassPainter extends CustomPainter {
double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0); double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0);
// Calculate contact position radius (from center to rim based on distance) // Calculate contact position radius (from center to rim based on distance)
final contactRadius = radius * normalizedDistance * 0.85; // 0.85 to keep inside rim final contactRadius =
radius * normalizedDistance * 0.85; // 0.85 to keep inside rim
// Position of contact dot // Position of contact dot
final dotX = center.dx + contactRadius * cos(angle); final dotX = center.dx + contactRadius * cos(angle);
@@ -288,11 +304,7 @@ class _LargeCompassPainter extends CustomPainter {
..color = Colors.lightBlue.withValues(alpha: 0.3) ..color = Colors.lightBlue.withValues(alpha: 0.3)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 1.5; ..strokeWidth = 1.5;
canvas.drawLine( canvas.drawLine(center, Offset(dotX, dotY), linePaint);
center,
Offset(dotX, dotY),
linePaint,
);
// Draw contact dot (size varies with zoom) // Draw contact dot (size varies with zoom)
final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0); final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0);
@@ -341,7 +353,10 @@ class _LargeCompassPainter extends CustomPainter {
textPainter.paint( textPainter.paint(
canvas, canvas,
Offset(labelX - textPainter.width / 2, labelY - textPainter.height / 2), Offset(
labelX - textPainter.width / 2,
labelY - textPainter.height / 2,
),
); );
} }
} }
@@ -419,11 +434,7 @@ class _LargeCompassPainter extends CustomPainter {
..color = markerColor.withValues(alpha: 0.3) ..color = markerColor.withValues(alpha: 0.3)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 2; ..strokeWidth = 2;
canvas.drawLine( canvas.drawLine(center, Offset(dotX, dotY), linePaint);
center,
Offset(dotX, dotY),
linePaint,
);
// Draw SAR marker dot (slightly larger than contacts) // Draw SAR marker dot (slightly larger than contacts)
final dotSize = (8.0 * (1.0 + zoomLevel * 0.3)).clamp(6.0, 14.0); final dotSize = (8.0 * (1.0 + zoomLevel * 0.3)).clamp(6.0, 14.0);
@@ -472,7 +483,10 @@ class _LargeCompassPainter extends CustomPainter {
textPainter.paint( textPainter.paint(
canvas, canvas,
Offset(labelX - textPainter.width / 2, labelY - textPainter.height / 2), Offset(
labelX - textPainter.width / 2,
labelY - textPainter.height / 2,
),
); );
} }
} }
@@ -492,27 +506,31 @@ class _LargeCompassPainter extends CustomPainter {
canvas.drawPath(path, indicatorPaint); canvas.drawPath(path, indicatorPaint);
} }
double _calculateBearing( double _calculateBearing(double lat1, double lon1, double lat2, double lon2) {
double lat1, double lon1, double lat2, double lon2) {
final dLon = (lon2 - lon1) * pi / 180; final dLon = (lon2 - lon1) * pi / 180;
final lat1Rad = lat1 * pi / 180; final lat1Rad = lat1 * pi / 180;
final lat2Rad = lat2 * pi / 180; final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad); final y = sin(dLon) * cos(lat2Rad);
final x = cos(lat1Rad) * sin(lat2Rad) - final x =
sin(lat1Rad) * cos(lat2Rad) * cos(dLon); cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi; final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360; return (bearing + 360) % 360;
} }
double _calculateDistance( double _calculateDistance(
double lat1, double lon1, double lat2, double lon2) { double lat1,
double lon1,
double lat2,
double lon2,
) {
const R = 6371000; // Earth's radius in meters const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180; final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180; final dLon = (lon2 - lon1) * pi / 180;
final a = sin(dLat / 2) * sin(dLat / 2) + final a =
sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) * cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) * cos(lat2 * pi / 180) *
sin(dLon / 2) * sin(dLon / 2) *
@@ -572,7 +590,8 @@ class _LocationFormatToggleState extends State<_LocationFormatToggle> {
final String displayText; final String displayText;
if (_showDMS) { if (_showDMS) {
displayText = '${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}'; displayText =
'${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}';
} else { } else {
displayText = l10n.latLonFormat( displayText = l10n.latLonFormat(
position.latitude.toStringAsFixed(5), position.latitude.toStringAsFixed(5),
@@ -598,9 +617,9 @@ class _LocationFormatToggleState extends State<_LocationFormatToggle> {
displayText, displayText,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontFamily: 'monospace', fontFamily: 'monospace',
), ),
), ),
), ),
), ),

View File

@@ -0,0 +1,22 @@
import 'dart:math';
/// Rotate the compass rose opposite the device heading so the current heading
/// stays under the fixed indicator at the top of the dial.
double compassRoseRotationRadians(double headingDegrees) {
final normalizedHeading = headingDegrees % 360;
return _normalizeRadians(-normalizedHeading * pi / 180);
}
/// Convert a compass marker bearing into an on-screen angle for the dial.
double compassDialAngleRadians({
required double markerDegrees,
required double headingDegrees,
}) {
return _normalizeRadians(
markerDegrees * pi / 180 -
pi / 2 +
compassRoseRotationRadians(headingDegrees),
);
}
double _normalizeRadians(double angle) => atan2(sin(angle), cos(angle));

View File

@@ -1,5 +1,6 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'compass/compass_math.dart';
class CompassWidget extends StatelessWidget { class CompassWidget extends StatelessWidget {
final double heading; final double heading;
@@ -21,9 +22,9 @@ class CompassWidget extends StatelessWidget {
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
// Compass rose background - rotates to show true north at top // Rotate the rose opposite the heading under the fixed needle.
Transform.rotate( Transform.rotate(
angle: heading * pi / 180, angle: compassRoseRotationRadians(heading),
child: CustomPaint( child: CustomPaint(
size: const Size(40, 40), size: const Size(40, 40),
painter: _CompassRosePainter(), painter: _CompassRosePainter(),
@@ -76,9 +77,7 @@ class _CompassRosePainter extends CustomPainter {
canvas.drawCircle(center, radius, paint); canvas.drawCircle(center, radius, paint);
// Draw cardinal direction markers // Draw cardinal direction markers
final textPainter = TextPainter( final textPainter = TextPainter(textDirection: TextDirection.ltr);
textDirection: TextDirection.ltr,
);
final directions = ['N', 'E', 'S', 'W']; final directions = ['N', 'E', 'S', 'W'];
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {

File diff suppressed because it is too large Load Diff

View File

@@ -211,8 +211,10 @@ List<SensorMetricOption> sensorMetricOptionsFor(
// Skip them from extraSensorData to avoid duplicates. // Skip them from extraSensorData to avoid duplicates.
final coreFieldKeys = <String>{ final coreFieldKeys = <String>{
if (batteryMilliVolts != null || batteryPercentage != null) if (batteryMilliVolts != null || batteryPercentage != null)
...extraSensorData?.keys.where((k) => ...extraSensorData?.keys.where(
k.startsWith('voltage_') || k.startsWith('analog_input_')) ?? [], (k) => k.startsWith('voltage_') || k.startsWith('analog_input_'),
) ??
[],
if (temperature != null) if (temperature != null)
...extraSensorData?.keys.where((k) => k.startsWith('temperature_')) ?? [], ...extraSensorData?.keys.where((k) => k.startsWith('temperature_')) ?? [],
if (humidity != null) if (humidity != null)
@@ -290,14 +292,14 @@ SensorMetricCardData? _buildOptionPreviewCardData(
); );
case 'illuminance': case 'illuminance':
final lux = _previewAsDouble(value); final lux = _previewAsDouble(value);
final irradiance = lux == null
? null
: _previewFormatDaylightIrradiance(lux);
return SensorMetricCardData( return SensorMetricCardData(
fieldKey: fieldKey, fieldKey: fieldKey,
icon: Icons.light_mode_outlined, icon: Icons.light_mode_outlined,
label: label, label: label,
value: previewValue, value: irradiance ?? previewValue,
secondaryValue: lux == null
? null
: '~${_formatPreviewNumber(_previewApproxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2 daylight',
accent: const Color(0xFFC17B1D), accent: const Color(0xFFC17B1D),
channel: metricKey.channel, channel: metricKey.channel,
); );
@@ -674,6 +676,10 @@ double _previewApproxDaylightIrradiance(double lux) {
return lux / 120.0; return lux / 120.0;
} }
String _previewFormatDaylightIrradiance(double lux) {
return '~${_formatPreviewNumber(_previewApproxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2';
}
String _previewFormatCardinalDirection(double degrees) { String _previewFormatCardinalDirection(double degrees) {
const points = <String>['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; const points = <String>['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
final normalized = ((degrees % 360) + 360) % 360; final normalized = ((degrees % 360) + 360) % 360;
@@ -699,7 +705,7 @@ String? _sensorMetricPreviewValue(String rawKey, dynamic value) {
case 'illuminance': case 'illuminance':
final lux = _previewAsDouble(value); final lux = _previewAsDouble(value);
if (lux == null) return null; if (lux == null) return null;
return '${_formatPreviewNumber(lux, maxFractionDigits: 0)} lx'; return _previewFormatDaylightIrradiance(lux);
case 'presence': case 'presence':
final isPresent = _previewAsBool(value); final isPresent = _previewAsBool(value);
@@ -1439,9 +1445,7 @@ class SensorTelemetryCard extends StatelessWidget {
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.light_mode_outlined, icon: Icons.light_mode_outlined,
label: label, label: label,
value: '${_formatNumber(lux, maxFractionDigits: 0)} lx', value: _formatDaylightIrradiance(lux),
secondaryValue:
'~${_formatNumber(_approxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2 daylight',
accent: const Color(0xFFC17B1D), accent: const Color(0xFFC17B1D),
channel: metricKey.channel, channel: metricKey.channel,
); );
@@ -1958,6 +1962,10 @@ class SensorTelemetryCard extends StatelessWidget {
return lux / 120.0; return lux / 120.0;
} }
String _formatDaylightIrradiance(double lux) {
return '~${_formatNumber(_approxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2';
}
String _formatCurrent(double amps) { String _formatCurrent(double amps) {
final absolute = amps.abs(); final absolute = amps.abs();
if (absolute < 1.0) { if (absolute < 1.0) {

View File

@@ -9,6 +9,7 @@
#include <audioplayers_linux/audioplayers_linux_plugin.h> #include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <flutter_avif_linux/flutter_avif_linux_plugin.h> #include <flutter_avif_linux/flutter_avif_linux_plugin.h>
#include <flutter_libserialport/flutter_libserialport_plugin.h>
#include <record_linux/record_linux_plugin.h> #include <record_linux/record_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
@@ -22,6 +23,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar = g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin");
flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar); flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_libserialport_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterLibserialportPlugin");
flutter_libserialport_plugin_register_with_registrar(flutter_libserialport_registrar);
g_autoptr(FlPluginRegistrar) record_linux_registrar = g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar); record_linux_plugin_register_with_registrar(record_linux_registrar);

View File

@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux audioplayers_linux
file_selector_linux file_selector_linux
flutter_avif_linux flutter_avif_linux
flutter_libserialport
record_linux record_linux
url_launcher_linux url_launcher_linux
) )

View File

@@ -11,6 +11,7 @@ import file_picker
import file_selector_macos import file_selector_macos
import flutter_avif_macos import flutter_avif_macos
import flutter_blue_plus_darwin import flutter_blue_plus_darwin
import flutter_libserialport
import flutter_local_notifications import flutter_local_notifications
import geolocator_apple import geolocator_apple
import nsd_macos import nsd_macos
@@ -30,6 +31,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterAvifPlugin.register(with: registry.registrar(forPlugin: "FlutterAvifPlugin")) FlutterAvifPlugin.register(with: registry.registrar(forPlugin: "FlutterAvifPlugin"))
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
FlutterLibserialportPlugin.register(with: registry.registrar(forPlugin: "FlutterLibserialportPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin")) NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))

View File

@@ -1,4 +1,4 @@
platform :osx, '10.15' platform :osx, '26.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -38,6 +38,9 @@ end
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target) flutter_additional_macos_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '26.0'
end
# codec2_flutter uses ARM-specific register asm("sp") in debug_alloc.h that # codec2_flutter uses ARM-specific register asm("sp") in debug_alloc.h that
# does not compile on x86_64 macOS. Voice messages are iOS-only, so exclude # does not compile on x86_64 macOS. Voice messages are iOS-only, so exclude
# all C source files from the macOS codec2_flutter build entirely. # all C source files from the macOS codec2_flutter build entirely.

View File

@@ -15,12 +15,16 @@ PODS:
- flutter_blue_plus_darwin (0.0.2): - flutter_blue_plus_darwin (0.0.2):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- flutter_libserialport (0.0.1):
- FlutterMacOS
- libserialport
- flutter_local_notifications (0.0.1): - flutter_local_notifications (0.0.1):
- FlutterMacOS - FlutterMacOS
- FlutterMacOS (1.0.0) - FlutterMacOS (1.0.0)
- geolocator_apple (1.2.0): - geolocator_apple (1.2.0):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- libserialport (0.1.1)
- nsd_macos (0.0.1): - nsd_macos (0.0.1):
- FlutterMacOS - FlutterMacOS
- package_info_plus (0.0.1): - package_info_plus (0.0.1):
@@ -40,6 +44,8 @@ PODS:
- FlutterMacOS - FlutterMacOS
- url_launcher_macos (0.0.1): - url_launcher_macos (0.0.1):
- FlutterMacOS - FlutterMacOS
- wakelock_plus (0.0.1):
- FlutterMacOS
DEPENDENCIES: DEPENDENCIES:
- audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`) - audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`)
@@ -49,6 +55,7 @@ DEPENDENCIES:
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- flutter_avif_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos`) - flutter_avif_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos`)
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- flutter_libserialport (from `Flutter/ephemeral/.symlinks/plugins/flutter_libserialport/macos`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- FlutterMacOS (from `Flutter/ephemeral`) - FlutterMacOS (from `Flutter/ephemeral`)
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`) - geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`)
@@ -60,6 +67,11 @@ DEPENDENCIES:
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
SPEC REPOS:
trunk:
- libserialport
EXTERNAL SOURCES: EXTERNAL SOURCES:
audioplayers_darwin: audioplayers_darwin:
@@ -76,6 +88,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos
flutter_blue_plus_darwin: flutter_blue_plus_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin :path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
flutter_libserialport:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_libserialport/macos
flutter_local_notifications: flutter_local_notifications:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
FlutterMacOS: FlutterMacOS:
@@ -98,6 +112,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
url_launcher_macos: url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
wakelock_plus:
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
SPEC CHECKSUMS: SPEC CHECKSUMS:
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
@@ -107,9 +123,11 @@ SPEC CHECKSUMS:
file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7
flutter_avif_macos: 9ed61d67adfbd6964eccb59971020fb55c31fc11 flutter_avif_macos: 9ed61d67adfbd6964eccb59971020fb55c31fc11
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
flutter_libserialport: 2c523cb8d8203fc6d1b0da88190da31ac970eb93
flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0 flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
libserialport: 1cb25e66ef3c92a8e59c2ea3820302c3fa2268cd
nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3 nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3
package_info_plus: f0052d280d17aa382b932f399edf32507174e870 package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
@@ -118,7 +136,8 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
PODFILE CHECKSUM: e3de8f2486e300492cc80a2fe0576e9d269ba22b PODFILE CHECKSUM: 467066e8d30d1105257e0877b8cdd20642e7b3db
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View File

@@ -557,7 +557,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15; MACOSX_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx; SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
@@ -639,7 +639,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15; MACOSX_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx; SDKROOT = macosx;
@@ -689,7 +689,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15; MACOSX_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx; SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;

View File

@@ -2,11 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key> <key>com.apple.security.cs.allow-jit</key>
<true/> <true/>
<key>com.apple.security.network.server</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -24,10 +24,16 @@
<false/> <false/>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string> <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>MeshCore SAR needs Bluetooth to discover and communicate with MeshCore devices during SAR operations</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>MeshCore SAR needs Bluetooth to discover and communicate with MeshCore devices during SAR operations</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_COPYRIGHT)</string> <string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key> <key>NSMainNibFile</key>
<string>MainMenu</string> <string>MainMenu</string>
<key>NSLocationUsageDescription</key>
<string>MeshCore SAR needs your location to track your team, share your position over the mesh network, and show your location on the map during SAR operations</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations</string> <string>MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>

View File

@@ -2,7 +2,5 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.app-sandbox</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -218,6 +218,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.3" version: "7.0.3"
dylib:
dependency: transitive
description:
name: dylib
sha256: bf609b3eb6492a3309b3d1dbe8f83a4031de5535dd7686be33487051cc760bb0
url: "https://pub.dev"
source: hosted
version: "0.3.3"
exif: exif:
dependency: transitive dependency: transitive
description: description:
@@ -479,6 +487,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.14.4" version: "0.14.4"
flutter_libserialport:
dependency: "direct main"
description:
name: flutter_libserialport
sha256: f24b5fe6f1821d1c1b3bf2be737ca53f6b0ae78657032a790aa00abb810c8759
url: "https://pub.dev"
source: hosted
version: "0.6.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -759,6 +775,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.2" version: "3.0.2"
libserialport:
dependency: transitive
description:
name: libserialport
sha256: "392e1592def65282429832ec66fa25e9e163d3b37716b97691482e2406720727"
url: "https://pub.dev"
source: hosted
version: "0.3.0+1"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@@ -788,7 +812,7 @@ packages:
description: description:
path: "." path: "."
ref: main ref: main
resolved-ref: abff892fac0dd0e8004bfa1f415df74b44b8eb52 resolved-ref: "3dbc82170583f616b590e5204a307a02f2b8833d"
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"
@@ -1477,6 +1501,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
webserial:
dependency: "direct main"
description:
name: webserial
sha256: b39c2175190ee1c75e996bf137bfee3e3e8fd06ee0ec24f0d3c10bdf7b323786
url: "https://pub.dev"
source: hosted
version: "1.2.0"
win32: win32:
dependency: transitive dependency: transitive
description: description:

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.0317.1+33 version: 2026.0319.1+35
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -123,6 +123,8 @@ dependencies:
nsd: ^4.0.3 nsd: ^4.0.3
usb_serial: ^0.5.2 usb_serial: ^0.5.2
web: ^1.1.1 web: ^1.1.1
flutter_libserialport: ^0.6.0
webserial: ^1.2.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -0,0 +1,35 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/channel.dart';
void main() {
test('hash channels expose deterministic psk_base64', () {
final channel = Channel.create(index: 3, name: '#ops');
expect(channel.isHashChannel, isTrue);
expect(channel.pskBase64, 'O2RN43fDLHh5NgWiWqkVvw==');
expect(
Channel.pskBase64ForHashChannelName('#ops'),
'O2RN43fDLHh5NgWiWqkVvw==',
);
});
test('normal channels reject derived hashtag psk export helper', () {
expect(
() => Channel.pskBase64ForHashChannelName('ops'),
throwsArgumentError,
);
});
test('normal channels still expose their stored psk_base64', () {
final channel = Channel.create(
index: 4,
name: 'ops',
explicitSecret: Uint8List.fromList(List<int>.generate(16, (i) => i)),
);
expect(channel.isHashChannel, isFalse);
expect(channel.pskBase64, 'AAECAwQFBgcICQoLDA0ODw==');
});
}

View File

@@ -1,22 +1,80 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/channel.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/contact_group.dart'; import 'package:meshcore_sar_app/models/contact_group.dart';
import 'package:meshcore_sar_app/models/device_info.dart' as device_info;
import 'package:meshcore_sar_app/providers/app_provider.dart';
import 'package:meshcore_sar_app/providers/channels_provider.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart'; import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart'; import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/providers/drawing_provider.dart';
import 'package:meshcore_sar_app/providers/image_provider.dart'
as image_provider;
import 'package:meshcore_sar_app/providers/map_provider.dart'; import 'package:meshcore_sar_app/providers/map_provider.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/providers/voice_provider.dart';
import 'package:meshcore_sar_app/screens/contacts_tab.dart'; import 'package:meshcore_sar_app/screens/contacts_tab.dart';
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
import 'package:meshcore_sar_app/services/voice_codec_service.dart';
import 'package:meshcore_sar_app/services/voice_player_service.dart';
import 'package:meshcore_sar_app/utils/contact_grouping.dart'; import 'package:meshcore_sar_app/utils/contact_grouping.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class _FakeConnectionProvider extends ConnectionProvider {
_FakeConnectionProvider({required bool isConnected})
: _isConnected = isConnected;
final bool _isConnected;
@override
device_info.DeviceInfo get deviceInfo => device_info.DeviceInfo(
connectionState: _isConnected
? device_info.ConnectionState.connected
: device_info.ConnectionState.disconnected,
);
}
class _FakeMessagesProvider extends MessagesProvider {
@override
bool get isInitialized => true;
}
class _FakeDrawingProvider extends DrawingProvider {
@override
bool get isInitialized => true;
}
void main() { void main() {
String? clipboardText;
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
ProfileStorageScope.setScope(
profilesEnabled: false,
activeProfileId: 'default',
);
clipboardText = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
switch (call.method) {
case 'Clipboard.setData':
clipboardText =
(call.arguments as Map<dynamic, dynamic>)['text'] as String?;
return null;
case 'Clipboard.getData':
return <String, dynamic>{'text': clipboardText};
}
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
}); });
Contact buildChannel({required String name, required int channelIndex}) { Contact buildChannel({required String name, required int channelIndex}) {
@@ -80,8 +138,28 @@ void main() {
WidgetTester tester, { WidgetTester tester, {
List<Contact> contacts = const [], List<Contact> contacts = const [],
List<SavedContactGroup> savedGroups = const [], List<SavedContactGroup> savedGroups = const [],
ConnectionProvider? connectionProvider,
}) async { }) async {
final contactsProvider = ContactsProvider(); final contactsProvider = ContactsProvider();
final resolvedConnectionProvider =
connectionProvider ?? ConnectionProvider();
final messagesProvider = _FakeMessagesProvider();
final drawingProvider = _FakeDrawingProvider();
final channelsProvider = ChannelsProvider();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = image_provider.ImageProvider();
final appProvider = AppProvider(
connectionProvider: resolvedConnectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
drawingProvider: drawingProvider,
channelsProvider: channelsProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
);
for (final contact in contacts) { for (final contact in contacts) {
contactsProvider.addOrUpdateContact(contact); contactsProvider.addOrUpdateContact(contact);
} }
@@ -95,10 +173,17 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MultiProvider( MultiProvider(
providers: [ providers: [
ChangeNotifierProvider.value(value: contactsProvider), ChangeNotifierProvider<ContactsProvider>(
ChangeNotifierProvider(create: (_) => ConnectionProvider()), create: (_) => contactsProvider,
ChangeNotifierProvider(create: (_) => MessagesProvider()), ),
ChangeNotifierProvider<ConnectionProvider>(
create: (_) => resolvedConnectionProvider,
),
ChangeNotifierProvider<MessagesProvider>(
create: (_) => messagesProvider,
),
ChangeNotifierProvider(create: (_) => MapProvider()), ChangeNotifierProvider(create: (_) => MapProvider()),
ChangeNotifierProvider<AppProvider>(create: (_) => appProvider),
], ],
child: MaterialApp( child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: AppLocalizations.localizationsDelegates,
@@ -123,6 +208,7 @@ void main() {
await tester.tap(find.text('Ops')); await tester.tap(find.text('Ops'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Export psk_base64'), findsNothing);
expect(find.text('Delete Channel'), findsOneWidget); expect(find.text('Delete Channel'), findsOneWidget);
await tester.tap(find.text('Delete Channel')); await tester.tap(find.text('Delete Channel'));
@@ -137,6 +223,26 @@ void main() {
); );
}); });
testWidgets('hash channel activity card exports psk_base64', (tester) async {
await pumpContactsTab(
tester,
contacts: [buildChannel(name: '#ops', channelIndex: 3)],
);
expect(find.text('#ops'), findsOneWidget);
await tester.tap(find.text('#ops'));
await tester.pumpAndSettle();
expect(find.text('Export psk_base64'), findsOneWidget);
await tester.tap(find.text('Export psk_base64'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(clipboardText, Channel.pskBase64ForHashChannelName('#ops'));
expect(find.text('psk_base64 copied to clipboard'), findsOneWidget);
});
testWidgets('repeaters show Others group when multiple groups exist', ( testWidgets('repeaters show Others group when multiple groups exist', (
tester, tester,
) async { ) async {
@@ -218,7 +324,25 @@ void main() {
contacts: [buildSensor(seed: 60, name: 'WX Station')], contacts: [buildSensor(seed: 60, name: 'WX Station')],
); );
expect(find.text('Sensors'), findsOneWidget); expect(find.text('Sensors'), findsWidgets);
expect(find.text('WX Station'), findsOneWidget); expect(find.text('WX Station'), findsOneWidget);
}); });
testWidgets(
'contacts sections do not show sensor or repeater discovery actions',
(tester) async {
await pumpContactsTab(
tester,
contacts: [
buildRepeater(seed: 70, name: 'Relay 1'),
buildSensor(seed: 71, name: 'WX Station'),
],
connectionProvider: _FakeConnectionProvider(isConnected: true),
);
expect(find.byTooltip('Discover repeaters'), findsNothing);
expect(find.byTooltip('Discover sensors'), findsNothing);
expect(find.byIcon(Icons.radar), findsNothing);
},
);
} }

View File

@@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/ble_packet_log.dart'; import 'package:meshcore_sar_app/models/ble_packet_log.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/screens/live_traffic_screen.dart'; import 'package:meshcore_sar_app/screens/live_traffic_screen.dart';
BlePacketLog _log({ BlePacketLog _log({
@@ -50,6 +51,14 @@ List<int> _multiHopRaw({
]; ];
} }
Widget _testApp(Widget child) {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: child,
);
}
void main() { void main() {
testWidgets('shows empty state before traffic arrives', (tester) async { testWidgets('shows empty state before traffic arrives', (tester) async {
final logs = <BlePacketLog>[]; final logs = <BlePacketLog>[];
@@ -57,8 +66,8 @@ void main() {
DateTime now = DateTime(2026, 3, 12, 12, 0, 0); DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( _testApp(
home: LiveTrafficScreen( LiveTrafficScreen(
logReader: () => logs, logReader: () => logs,
refreshListenable: refresh, refreshListenable: refresh,
now: () => now, now: () => now,
@@ -78,10 +87,9 @@ void main() {
DateTime now = DateTime(2026, 3, 12, 12, 0, 0); DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( _testApp(
home: LiveTrafficScreen( LiveTrafficScreen(
logReader: () => logs, logReader: () => logs,
rxCountReader: () => 7,
refreshListenable: refresh, refreshListenable: refresh,
now: () => now, now: () => now,
), ),
@@ -114,10 +122,9 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.text('1 pkt/min'), findsOneWidget); expect(find.text('1 pkt/min'), findsOneWidget);
expect(find.text('Device total 7'), findsOneWidget); expect(find.text('Device total 1'), findsOneWidget);
expect(find.text('FLOOD RESPONSE'), findsOneWidget); expect(find.text('FLOOD RESPONSE'), findsOneWidget);
expect(find.text('MULTI-HOP'), findsOneWidget); expect(find.text('-84 dBm'), findsOneWidget);
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
expect(find.textContaining('Hash:'), findsOneWidget); expect(find.textContaining('Hash:'), findsOneWidget);
expect( expect(
find.textContaining('Path: 3 hops [c010,6301,68d9]'), find.textContaining('Path: 3 hops [c010,6301,68d9]'),
@@ -143,8 +150,8 @@ void main() {
); );
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( _testApp(
home: LiveTrafficScreen( LiveTrafficScreen(
logReader: () => logs, logReader: () => logs,
refreshListenable: refresh, refreshListenable: refresh,
now: () => now, now: () => now,
@@ -152,7 +159,7 @@ void main() {
), ),
); );
expect(find.text('MULTI-HOP'), findsOneWidget); expect(find.text('FLOOD RESPONSE'), findsOneWidget);
await tester.tap(find.byTooltip('Clear live view')); await tester.tap(find.byTooltip('Clear live view'));
await tester.pump(); await tester.pump();
@@ -182,4 +189,43 @@ void main() {
expect(find.text('No packets for this filter'), findsNothing); expect(find.text('No packets for this filter'), findsNothing);
expect(find.textContaining('Size: 3 bytes'), findsOneWidget); expect(find.textContaining('Size: 3 bytes'), findsOneWidget);
}); });
testWidgets('summary metrics expand across wide layouts', (tester) async {
tester.view.physicalSize = const Size(1200, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final now = DateTime(2026, 3, 12, 12, 0, 0);
final logs = [
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01]),
responseCode: 0x88,
snrDb: 9.5,
rssiDbm: -82,
),
];
await tester.pumpWidget(
_testApp(LiveTrafficScreen(logReader: () => logs, now: () => now)),
);
expect(
tester
.getSize(
find.byKey(const ValueKey('liveTrafficMetric:rxPackets')),
)
.width >
200,
isTrue,
);
expect(
tester
.getSize(find.byKey(const ValueKey('liveTrafficMetric:multihop')))
.width >
200,
isTrue,
);
});
} }

View File

@@ -146,23 +146,55 @@ void main() {
test('supports clearing the live view without mutating source logs', () { test('supports clearing the live view without mutating source logs', () {
final now = DateTime(2026, 3, 12, 12, 0, 0); final now = DateTime(2026, 3, 12, 12, 0, 0);
final clearAt = now.subtract(const Duration(seconds: 8)); final clearAt = now.subtract(const Duration(seconds: 8));
final snapshot = LiveTrafficSummary.fromLogs([ final snapshot = LiveTrafficSummary.fromLogs(
_log( [
timestamp: now.subtract(const Duration(seconds: 10)), _log(
direction: PacketDirection.rx, timestamp: now.subtract(const Duration(seconds: 10)),
rawData: [0x88, 0x00, 0x00], direction: PacketDirection.rx,
responseCode: 0x88, rawData: [0x88, 0x00, 0x00],
), responseCode: 0x88,
_log( ),
timestamp: now.subtract(const Duration(seconds: 4)), _log(
direction: PacketDirection.rx, timestamp: now.subtract(const Duration(seconds: 4)),
rawData: [0x88, 0x00, 0x00], direction: PacketDirection.rx,
responseCode: 0x88, rawData: [0x88, 0x00, 0x00],
), responseCode: 0x88,
], now: now, clearedAt: clearAt); ),
],
now: now,
clearedAt: clearAt,
);
expect(snapshot.totalCount, 1); expect(snapshot.totalCount, 1);
expect(snapshot.visibleEntries, hasLength(1)); expect(snapshot.visibleEntries, hasLength(1));
}); });
test('counts only RX data packets for device totals', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
expect(
LiveTrafficSummary.countRxDataLogs([
_log(
timestamp: now.subtract(const Duration(seconds: 30)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 20)),
direction: PacketDirection.tx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx,
rawData: [0x05, 0x01, 0x02],
responseCode: 0x05,
),
]),
1,
);
});
}); });
} }

View File

@@ -0,0 +1,36 @@
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/widgets/map/compass/compass_math.dart';
void main() {
group('compass dial math', () {
test('rotates the rose opposite the heading', () {
expect(compassRoseRotationRadians(0), closeTo(0, 1e-10));
expect(compassRoseRotationRadians(90), closeTo(-pi / 2, 1e-10));
expect(compassRoseRotationRadians(450), closeTo(-pi / 2, 1e-10));
});
test('places east at the top when heading east', () {
expect(
compassDialAngleRadians(markerDegrees: 90, headingDegrees: 90),
closeTo(-pi / 2, 1e-10),
);
expect(
compassDialAngleRadians(markerDegrees: 270, headingDegrees: 90),
closeTo(pi / 2, 1e-10),
);
});
test('places west at the top when heading west', () {
expect(
compassDialAngleRadians(markerDegrees: 270, headingDegrees: 270),
closeTo(-pi / 2, 1e-10),
);
expect(
compassDialAngleRadians(markerDegrees: 90, headingDegrees: 270),
closeTo(pi / 2, 1e-10),
);
});
});
}

View File

@@ -164,18 +164,16 @@ void main() {
expect(find.text('CO2'), findsOneWidget); expect(find.text('CO2'), findsOneWidget);
expect(find.text('415 ppm'), findsOneWidget); expect(find.text('415 ppm'), findsOneWidget);
expect(find.text('Illuminance'), findsOneWidget); expect(find.text('Illuminance'), findsOneWidget);
expect(find.text('~4.2 W/m2 daylight'), findsOneWidget); expect(find.text('~4.2 W/m2'), findsOneWidget);
expect(find.textContaining('lx'), findsNothing);
expect(find.text('Current'), findsOneWidget); expect(find.text('Current'), findsOneWidget);
expect(find.text('15 mA'), findsOneWidget); expect(find.text('15 mA'), findsOneWidget);
expect(find.text('Power'), findsOneWidget); expect(find.text('Power'), findsOneWidget);
expect(find.text('Distance'), findsOneWidget); expect(find.text('Distance'), findsOneWidget);
expect( expect(find.byKey(const ValueKey('sensor_metric_battery')), findsOneWidget);
find.byKey(const ValueKey('sensor_metric_channel_battery')),
findsOneWidget,
);
expect(find.text('ch1'), findsWidgets); expect(find.text('ch1'), findsWidgets);
expect( expect(
find.byKey(const ValueKey('sensor_metric_channel_extra:illuminance_2')), find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')),
findsOneWidget, findsOneWidget,
); );

View File

@@ -11,6 +11,8 @@ void main() {
required String name, required String name,
required ContactType type, required ContactType type,
int secondByte = 0, int secondByte = 0,
int flags = 0,
int lastAdvert = 0,
}) { }) {
final publicKey = Uint8List(32); final publicKey = Uint8List(32);
publicKey[1] = secondByte; publicKey[1] = secondByte;
@@ -18,11 +20,11 @@ void main() {
return Contact( return Contact(
publicKey: publicKey, publicKey: publicKey,
type: type, type: type,
flags: 0, flags: flags,
outPathLen: 0, outPathLen: 0,
outPath: Uint8List(0), outPath: Uint8List(0),
advName: name, advName: name,
lastAdvert: 0, lastAdvert: lastAdvert,
advLat: 0, advLat: 0,
advLon: 0, advLon: 0,
lastMod: 0, lastMod: 0,
@@ -98,4 +100,54 @@ void main() {
expect(find.text('Show all'), findsNothing); expect(find.text('Show all'), findsNothing);
expect(find.text('John Smith'), findsOneWidget); expect(find.text('John Smith'), findsOneWidget);
}); });
testWidgets('sorts contacts with favourites first, then last seen', (
tester,
) async {
final recentNonFavourite = buildContact(
name: 'Alpha',
type: ContactType.chat,
secondByte: 1,
lastAdvert: 300,
);
final olderFavourite = buildContact(
name: 'Bravo',
type: ContactType.chat,
secondByte: 2,
flags: 0x01,
lastAdvert: 100,
);
final newerFavourite = buildContact(
name: 'Charlie',
type: ContactType.chat,
secondByte: 3,
flags: 0x01,
lastAdvert: 200,
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: RecipientSelectorSheet(
contacts: [recentNonFavourite, olderFavourite, newerFavourite],
rooms: const [],
channels: const [],
unreadCount: 0,
unreadCountsByPublicKey: const {},
showAllOption: false,
onSelect: (selectedRecipient, draftMessage) {},
),
),
),
);
final charlieY = tester.getTopLeft(find.text('Charlie')).dy;
final bravoY = tester.getTopLeft(find.text('Bravo')).dy;
final alphaY = tester.getTopLeft(find.text('Alpha')).dy;
expect(charlieY, lessThan(bravoY));
expect(bravoY, lessThan(alphaY));
});
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; 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/screens/sensors_tab.dart'; import 'package:meshcore_sar_app/screens/sensors_tab.dart';
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart'; import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
@@ -7,6 +8,8 @@ void main() {
testWidgets('renders selector previews and channel badges', (tester) async { testWidgets('renders selector previews and channel badges', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold( home: Scaffold(
body: SensorMetricSelectorItem( body: SensorMetricSelectorItem(
option: const SensorMetricOption( option: const SensorMetricOption(
@@ -14,12 +17,12 @@ void main() {
label: 'Illuminance (ch 2)', label: 'Illuminance (ch 2)',
defaultLabel: 'Illuminance', defaultLabel: 'Illuminance',
channel: 2, channel: 2,
valuePreview: '500 lx', valuePreview: '~4.2 W/m2',
previewCardData: SensorMetricCardData( previewCardData: SensorMetricCardData(
fieldKey: 'extra:illuminance_2', fieldKey: 'extra:illuminance_2',
icon: Icons.light_mode_outlined, icon: Icons.light_mode_outlined,
label: 'Illuminance', label: 'Illuminance',
value: '500 lx', value: '~4.2 W/m2',
accent: Color(0xFFC17B1D), accent: Color(0xFFC17B1D),
channel: 2, channel: 2,
), ),
@@ -39,7 +42,8 @@ void main() {
); );
expect(find.text('Show on sensor card'), findsOneWidget); expect(find.text('Show on sensor card'), findsOneWidget);
expect(find.text('500 lx'), findsOneWidget); expect(find.text('~4.2 W/m2'), findsOneWidget);
expect(find.textContaining('lx'), findsNothing);
expect( expect(
find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')), find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')),
findsOneWidget, findsOneWidget,
@@ -50,6 +54,8 @@ void main() {
testWidgets('omits duplicate channel chip for channel 1', (tester) async { testWidgets('omits duplicate channel chip for channel 1', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold( home: Scaffold(
body: SensorMetricSelectorItem( body: SensorMetricSelectorItem(
option: const SensorMetricOption( option: const SensorMetricOption(

View File

@@ -63,12 +63,14 @@ void main() {
expect(find.text('Light'), findsOneWidget); expect(find.text('Light'), findsOneWidget);
expect(find.text('Temperature'), findsNothing); expect(find.text('Temperature'), findsNothing);
expect(find.text('Illuminance'), findsNothing); expect(find.text('Illuminance'), findsNothing);
expect(find.text('~4.2 W/m2'), findsOneWidget);
expect(find.textContaining('lx'), findsNothing);
expect( expect(
find.byKey(const ValueKey('sensor_metric_channel_temperature')), find.byKey(const ValueKey('sensor_metric_temperature')),
findsOneWidget, findsOneWidget,
); );
expect( expect(
find.byKey(const ValueKey('sensor_metric_channel_extra:illuminance_2')), find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')),
findsOneWidget, findsOneWidget,
); );
}); });

View File

@@ -10,6 +10,7 @@
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <flutter_avif_windows/flutter_avif_windows_plugin.h> #include <flutter_avif_windows/flutter_avif_windows_plugin.h>
#include <flutter_blue_plus_winrt/flutter_blue_plus_plugin.h> #include <flutter_blue_plus_winrt/flutter_blue_plus_plugin.h>
#include <flutter_libserialport/flutter_libserialport_plugin.h>
#include <geolocator_windows/geolocator_windows.h> #include <geolocator_windows/geolocator_windows.h>
#include <nsd_windows/nsd_windows_plugin_c_api.h> #include <nsd_windows/nsd_windows_plugin_c_api.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h> #include <permission_handler_windows/permission_handler_windows_plugin.h>
@@ -26,6 +27,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FlutterAvifWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterAvifWindowsPlugin"));
FlutterBluePlusPluginRegisterWithRegistrar( FlutterBluePlusPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterBluePlusPlugin")); registry->GetRegistrarForPlugin("FlutterBluePlusPlugin"));
FlutterLibserialportPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterLibserialportPlugin"));
GeolocatorWindowsRegisterWithRegistrar( GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows")); registry->GetRegistrarForPlugin("GeolocatorWindows"));
NsdWindowsPluginCApiRegisterWithRegistrar( NsdWindowsPluginCApiRegisterWithRegistrar(

View File

@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows file_selector_windows
flutter_avif_windows flutter_avif_windows
flutter_blue_plus_winrt flutter_blue_plus_winrt
flutter_libserialport
geolocator_windows geolocator_windows
nsd_windows nsd_windows
permission_handler_windows permission_handler_windows